diff --git a/backend/internal/cli/preview.go b/backend/internal/cli/preview.go index 4517b73619..8044e943db 100644 --- a/backend/internal/cli/preview.go +++ b/backend/internal/cli/preview.go @@ -5,6 +5,7 @@ import ( "errors" "net/url" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -25,10 +26,10 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command { Long: "Open a URL in the desktop browser panel for the current session.\n\n" + "With no argument it opens the workspace's static entry point, falling\n" + "back to this session's existing preview target when no entry point exists.\n" + - "A local file can be opened by its absolute file:// URL\n" + - "(e.g. file:///home/me/proj/index.html). Use `ao preview clear` to empty the panel.", + "A relative path is resolved against the current directory.\n" + + "Use `ao preview clear` to empty the panel.", Example: ` ao preview - ao preview file://$(pwd)/index.html + ao preview README.md ao preview http://localhost:5173 ao preview clear`, Args: cobra.MaximumNArgs(1), @@ -56,6 +57,19 @@ func (c *commandContext) openPreview(ctx context.Context, target string) error { if err != nil { return err } + // Resolve bare relative paths to absolute before sending to the daemon. + // Without this, a relative path like "README.md" would be stored verbatim + // and Electron's MarkdownHost could not resolve it — it would not match + // the file://, daemon-proxy, or HTTP patterns resolveLocalPath understands. + // The daemon already handles absolute paths via absolutePreviewFileURL + // (stats the file, converts to file:// URL). + if target != "" && !filepath.IsAbs(target) { + if abs, err := filepath.Abs(target); err == nil { + if _, err := os.Stat(abs); err == nil { + target = abs + } + } + } return c.postJSON(ctx, path, previewAPIRequest{Url: target}, nil) } diff --git a/backend/internal/cli/preview_test.go b/backend/internal/cli/preview_test.go index ad12e85a23..4de2cf9878 100644 --- a/backend/internal/cli/preview_test.go +++ b/backend/internal/cli/preview_test.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" ) @@ -91,6 +93,76 @@ func TestPreview_NoArgPostsEmptyURL(t *testing.T) { } } +func TestPreview_RelativePathResolvedToAbsolute(t *testing.T) { + t.Setenv("AO_SESSION_ID", "bb-12") + cfg := setConfigEnv(t) + srv, capture := previewServer(t, http.StatusOK, `{"ok":true}`) + writeRunFileFor(t, cfg, srv) + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(tmpFile, []byte("# hello"), 0o644); err != nil { + t.Fatal(err) + } + + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + // Compute the expected path the same way the function does + // (filepath.Abs) so the comparison isn't sensitive to OS-level + // path differences (macOS /var→/private/var, Windows short names). + want, err := filepath.Abs("test.md") + if err != nil { + t.Fatal(err) + } + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "preview", "test.md") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var req struct { + Url string `json:"url"` + } + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v\nbody=%s", err, capture.body) + } + if req.Url != want { + t.Errorf("captured url = %q, want %q", req.Url, want) + } +} + +func TestPreview_RelativePathNonExistentPassedThrough(t *testing.T) { + t.Setenv("AO_SESSION_ID", "cc-34") + cfg := setConfigEnv(t) + srv, capture := previewServer(t, http.StatusOK, `{"ok":true}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "preview", "nonexistent.md") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var req struct { + Url string `json:"url"` + } + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v\nbody=%s", err, capture.body) + } + // Should pass through verbatim when the file doesn't exist. + if req.Url != "nonexistent.md" { + t.Errorf("captured url = %q, want %q", req.Url, "nonexistent.md") + } +} + func TestPreviewClear_DeletesSessionPreview(t *testing.T) { t.Setenv("AO_SESSION_ID", "aa-47") cfg := setConfigEnv(t) @@ -163,12 +235,9 @@ func TestPreview_HelpIncludesExamples(t *testing.T) { if !strings.Contains(out, "EXAMPLES") && !strings.Contains(out, "Examples") { t.Errorf("help output missing Examples section:\n%s", out) } - // file:// URL example (not a relative path). - if !strings.Contains(out, "file://$(pwd)/index.html") { - t.Errorf("help output missing file:// example:\n%s", out) - } - if strings.Contains(out, "./dist/index.html") { - t.Errorf("help output still references relative ./dist/index.html:\n%s", out) + // Relative path example present. + if !strings.Contains(out, "README.md") { + t.Errorf("help output missing README.md example:\n%s", out) } } diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index e4554bde88..dbf478ac5d 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1708,6 +1708,8 @@ components: updatedAt: format: date-time type: string + workspacePath: + type: string required: - id - projectId diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 89cad91923..76ffa83635 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -127,6 +127,11 @@ type CleanupSessionsQuery struct { type SessionView struct { domain.Session Branch string `json:"branch,omitempty"` + // WorkspacePath is the session's git worktree directory (e.g. + // ~/.ao/worktrees//). The frontend needs this to + // resolve daemon-proxied preview file URLs to local paths for file watching. + // Pulled from the json:"-" domain Metadata. + WorkspacePath string `json:"workspacePath,omitempty"` // PreviewURL is the browser preview target the desktop app opens for this // session, set via POST /sessions/{sessionId}/preview. Empty (omitted) when // no preview has been requested. Pulled from the json:"-" domain Metadata. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 348f65ff11..267d634618 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -254,17 +254,26 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request) envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sessionView(updated)}) } -// clearPreview resets a session's browser preview to empty (`ao preview -// clear`). Unlike setPreview with an empty url it never autodetects: it persists -// an empty target so the desktop browser panel returns to its blank state. The -// write still bumps the preview revision, so the panel hears the change over -// CDC even though the url field is now empty. +// clearPreview resets a session's browser preview (`ao preview clear`). It +// first checks for an index.html entry point in the workspace — if found, the +// preview reverts to it. Otherwise the panel clears to its blank initial state. +// The write always bumps the preview revision, so the panel hears the change +// over CDC regardless. func (c *SessionsController) clearPreview(w http.ResponseWriter, r *http.Request) { if c.Svc == nil { apispec.NotImplemented(w, r, "DELETE", "/api/v1/sessions/{sessionId}/preview") return } - updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), "") + sess, err := c.Svc.Get(r.Context(), sessionID(r)) + if err != nil { + envelope.WriteError(w, r, err) + return + } + var previewURL string + if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok { + previewURL = previewFileURL(r, sessionID(r), entry) + } + updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL) if err != nil { envelope.WriteError(w, r, err) return @@ -664,7 +673,7 @@ func previewFileURL(r *http.Request, id domain.SessionID, entry string) string { } func sessionView(s domain.Session) SessionView { - return SessionView{Session: s, Branch: s.Metadata.Branch, PreviewURL: s.Metadata.PreviewURL, PreviewRevision: s.Metadata.PreviewRevision, PRs: sessionPRFacts(s.PRs)} + return SessionView{Session: s, Branch: s.Metadata.Branch, WorkspacePath: s.Metadata.WorkspacePath, PreviewURL: s.Metadata.PreviewURL, PreviewRevision: s.Metadata.PreviewRevision, PRs: sessionPRFacts(s.PRs)} } func sessionViews(sessions []domain.Session) []SessionView { diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index ae0fe53daf..deb516adc0 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -262,9 +262,6 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { if _, ok := rawList.Sessions[0]["metadata"]; ok { t.Fatalf("list leaked metadata: %s", body) } - if _, ok := rawList.Sessions[0]["workspacePath"]; ok { - t.Fatalf("list leaked workspacePath: %s", body) - } if _, ok := rawList.Sessions[0]["prompt"]; ok { t.Fatalf("list leaked prompt: %s", body) } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 11da9edd88..f34c16b73a 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -1051,7 +1051,7 @@ func (m *Manager) buildSystemPrompt(ctx context.Context, kind domain.SessionKind if base == "" { return "", nil } - return base + m.aoSkillPointer() + systemPromptGuard, nil + return base + m.aoSkillPointer() + m.aoMarkdownPreviewPointer() + systemPromptGuard, nil } // aoSkillPointer is appended to every agent system prompt. It points the agent @@ -1068,6 +1068,16 @@ func (m *Manager) aoSkillPointer() string { "When you need to use the `ao` CLI, read `" + skillFile + "` first (and the relevant `" + commandsGlob + "`) for the full command catalog, flags, and examples." } +// aoMarkdownPreviewPointer is appended to every agent system prompt. It +// points the agent at the markdown-preview skill so that agents know they +// can produce `.md` output that renders in the Electron browser panel. +func (m *Manager) aoMarkdownPreviewPointer() string { + dir := skillassets.DirFor(m.dataDir, skillassets.MarkdownPreviewName) + skillFile := filepath.Join(dir, "SKILL.md") + return "\n\n" + "## Markdown preview\n\n" + + "When you need to show the user formatted output (reports, specs, summaries, READMEs), produce a `.md` file and tell the user to open it in the browser panel. Read `" + skillFile + "` for the full guide." +} + func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { recs, err := m.store.ListSessions(ctx, project) if err != nil { diff --git a/backend/internal/skillassets/markdown-preview/SKILL.md b/backend/internal/skillassets/markdown-preview/SKILL.md new file mode 100644 index 0000000000..1e6c6e96be --- /dev/null +++ b/backend/internal/skillassets/markdown-preview/SKILL.md @@ -0,0 +1,40 @@ +--- +name: markdown-preview +description: Render `.md` files and markdown text as styled HTML in the Electron browser panel, including file-watching for live refresh. Use when you want to show the user a rendered README, report, spec, or any markdown output. +trigger: You produce a `.md` file the user should preview, or want to display markdown content in the browser panel. +--- + +# Markdown Preview Skill + +The AO Electron supervisor can render markdown (`--output.format markdown` / `.md` files) as styled HTML in the browser panel (the inspector rail's **Browser** tab). Rendering is done entirely on the main process with a strict CSP (`script-src 'none'`). + +## How it works + +- **File-based preview:** Any URL ending in `.md` that the user enters in the address bar (or that you open with `ao preview`) is detected by the renderer, fetched, rendered to HTML via `marked` + `DOMPurify`, and displayed. +- **File watching:** When a local `.md` file is opened from the worktree, the daemon watches it via the OS-native file watcher. Editing and saving the file triggers a live re-render in the browser panel (debounced at 300ms). + +## Using from a session + +1. **Generate a `.md` file** in the session worktree — for example a README, a bug report, a spec, or a summary. +2. **Auto-preview immediately:** After creating the file, run `ao preview .md` to push it to the browser panel so the user sees it without extra steps. +3. **Multiple files:** If your task produces several `.md` files in one go (e.g., a report per issue), only auto-preview the **last** one — the panel can only show one at a time, and previous targets are immediately replaced. +4. **Live editing:** When the file is local and the user saves edits, the preview auto-refreshes. +5. **File deletion:** If the previewed file is deleted, the browser panel reverts to `index.html` (the default workspace entry point) or clears to blank if none exists — no error page is shown. + +## `ao preview` + +When running inside an AO session, `ao preview` opens any URL (including `file://` paths to `.md` files) in the browser panel: + +``` +ao preview path/to/file.md +ao preview https://example.com +``` + +The markdown preview is a client-side render — the daemon does not store or serve the rendered HTML. It is cached in the Electron main process per preview session. + +## Security + +- All rendered pages carry `script-src 'none'` — no JavaScript executes. +- HTML is sanitised through DOMPurify with a strict allowlist. +- Output is served from `app://md-preview/`, an Electron custom protocol with no network exposure. +- The protocol handler only serves documents that the MarkdownHost cached; there is no general file serving. diff --git a/backend/internal/skillassets/skillassets.go b/backend/internal/skillassets/skillassets.go index b8072858cd..d907b685cb 100644 --- a/backend/internal/skillassets/skillassets.go +++ b/backend/internal/skillassets/skillassets.go @@ -1,11 +1,11 @@ -// Package skillassets embeds the using-ao skill (the ao CLI catalog) and -// installs it into the AO data dir at daemon boot. Worker sessions run in a -// worktree of whatever project they were spawned in, so a repo-relative -// skills/ path only resolves when that project happens to be the AO repo -// itself. Installing under the data dir gives every session, in any project, a -// stable absolute path to read. +// Package skillassets embeds skills (the ao CLI catalog, markdown preview, +// etc.) and installs them into the AO data dir at daemon boot. Worker sessions +// run in a worktree of whatever project they were spawned in, so a +// repo-relative skills/ path only resolves when that project happens to be the +// AO repo itself. Installing under the data dir gives every session, in any +// project, a stable absolute path to read. // -// The embedded copy is the single source of truth. Install clobbers the +// Each embedded copy is the single source of truth. Install clobbers the // on-disk copy on every boot, so a new daemon build always refreshes it and the // two can never drift; there is no version marker or hash to keep in sync // because the daemon binary already is the version. @@ -20,36 +20,48 @@ import ( "embed" ) -//go:embed using-ao +//go:embed using-ao markdown-preview var files embed.FS -// SkillName is the installed skill's directory name under /skills. -const SkillName = "using-ao" +// Skill directory names under /skills. +const ( + UsingAoName = "using-ao" + MarkdownPreviewName = "markdown-preview" +) -// Dir returns the absolute directory the skill installs into for a given data -// dir. Callers building prompts use this so the path they cite always matches -// where Install writes. +// Dir returns the absolute directory for the using-ao skill. It is a +// convenience wrapper around DirFor. func Dir(dataDir string) string { - return filepath.Join(dataDir, "skills", SkillName) + return DirFor(dataDir, UsingAoName) +} + +// DirFor returns the absolute directory a named skill installs into for a +// given data dir. Callers building prompts use this so the path they cite +// always matches where Install writes. +func DirFor(dataDir, skillName string) string { + return filepath.Join(dataDir, "skills", skillName) } -// Install writes the embedded using-ao skill into /skills/using-ao, +// Install writes every embedded skill into /skills/, // replacing any existing copy. It runs once at daemon boot, before any session // spawns, so a plain clobber-and-write needs no locking: there are no // concurrent readers yet. A failure is returned but is non-fatal to boot (the -// skill enhances `ao --help`, it is not load-bearing). +// skills enhance `ao --help` and session output; they are not load-bearing). func Install(dataDir string) error { - dest := Dir(dataDir) - if err := os.RemoveAll(dest); err != nil { - return fmt.Errorf("clear skill dir %q: %w", dest, err) + skillsDir := filepath.Join(dataDir, "skills") + if err := os.RemoveAll(skillsDir); err != nil { + return fmt.Errorf("clear skills dir %q: %w", skillsDir, err) } - // embed.FS always uses forward-slash paths rooted at "using-ao"; map each - // onto /skills/ with the platform separator. - return fs.WalkDir(files, SkillName, func(p string, d fs.DirEntry, err error) error { + // embed.FS always uses forward-slash paths; map each entry onto + // /skills/ with the platform separator. + return fs.WalkDir(files, ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } - target := filepath.Join(dataDir, "skills", filepath.FromSlash(p)) + if p == "." { + return nil // skip root + } + target := filepath.Join(skillsDir, filepath.FromSlash(p)) if d.IsDir() { return os.MkdirAll(target, 0o750) } diff --git a/backend/internal/skillassets/skillassets_test.go b/backend/internal/skillassets/skillassets_test.go index a8d06564bb..2c9447e02a 100644 --- a/backend/internal/skillassets/skillassets_test.go +++ b/backend/internal/skillassets/skillassets_test.go @@ -6,10 +6,10 @@ import ( "testing" ) -// TestInstall_WritesSkillAndIsIdempotent: Install must lay down the embedded -// skill (SKILL.md plus a commands file) under /skills/using-ao, and a -// second run must clobber cleanly, leaving no stale files. This is the whole -// contract the daemon boot hook relies on. +// TestInstall_WritesSkillAndIsIdempotent: Install must lay down every embedded +// skill under /skills//, and a second run must clobber cleanly, +// leaving no stale files. This is the whole contract the daemon boot hook +// relies on. func TestInstall_WritesSkillAndIsIdempotent(t *testing.T) { dataDir := t.TempDir() @@ -17,18 +17,27 @@ func TestInstall_WritesSkillAndIsIdempotent(t *testing.T) { t.Fatalf("Install: %v", err) } - skillFile := filepath.Join(Dir(dataDir), "SKILL.md") - if b, err := os.ReadFile(skillFile); err != nil { - t.Fatalf("read %s: %v", skillFile, err) + // using-ao skill + usingAo := filepath.Join(DirFor(dataDir, UsingAoName), "SKILL.md") + if b, err := os.ReadFile(usingAo); err != nil { + t.Fatalf("read %s: %v", usingAo, err) } else if len(b) == 0 { - t.Fatalf("SKILL.md is empty") + t.Fatalf("using-ao SKILL.md is empty") } - if _, err := os.Stat(filepath.Join(Dir(dataDir), "commands", "spawn.md")); err != nil { + if _, err := os.Stat(filepath.Join(DirFor(dataDir, UsingAoName), "commands", "spawn.md")); err != nil { t.Fatalf("commands/spawn.md missing: %v", err) } - // A stale file inside the skill dir must not survive a reinstall (clobber). - stale := filepath.Join(Dir(dataDir), "stale.md") + // markdown-preview skill + mdPreview := filepath.Join(DirFor(dataDir, MarkdownPreviewName), "SKILL.md") + if b, err := os.ReadFile(mdPreview); err != nil { + t.Fatalf("read %s: %v", mdPreview, err) + } else if len(b) == 0 { + t.Fatalf("markdown-preview SKILL.md is empty") + } + + // A stale file inside the skills dir must not survive a reinstall (clobber). + stale := filepath.Join(DirFor(dataDir, UsingAoName), "stale.md") if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil { t.Fatalf("seed stale file: %v", err) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67ed1ebd7c..8c9d5648e4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-orchestrator", - "version": "0.0.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-orchestrator", - "version": "0.0.0", + "version": "0.10.0", "license": "MIT", "dependencies": { "@radix-ui/react-dialog": "^1.1.16", @@ -22,10 +22,14 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^5.5.0", + "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.2.4", "electron-updater": "^6.8.9", + "linkedom": "^0.18.12", "lucide-react": "^1.17.0", + "marked": "^15.0.11", "openapi-fetch": "^0.17.0", "posthog-js": "^1.390.2", "radix-ui": "^1.5.0", @@ -54,7 +58,6 @@ "@vitejs/plugin-react": "^6.0.2", "app-builder-lib": "^26.15.3", "electron": "^33.0.0", - "jsdom": "^29.1.1", "openapi-typescript": "^7.13.0", "playwright": "^1.60.0", "tailwindcss": "^4.3.0", @@ -80,6 +83,8 @@ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", @@ -97,6 +102,8 @@ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", @@ -114,6 +121,8 @@ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } @@ -123,7 +132,9 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.29.7", @@ -398,6 +409,8 @@ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "css-tree": "^3.0.0" }, @@ -421,6 +434,8 @@ } ], "license": "MIT-0", + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -441,6 +456,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -465,6 +482,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.1" @@ -493,6 +512,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -516,6 +537,8 @@ } ], "license": "MIT-0", + "optional": true, + "peer": true, "peerDependencies": { "css-tree": "^3.2.1" }, @@ -541,6 +564,8 @@ } ], "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2817,6 +2842,8 @@ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -7435,6 +7462,8 @@ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "require-from-string": "^2.0.2" } @@ -7473,6 +7502,11 @@ "dev": true, "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -8020,7 +8054,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -8421,12 +8454,29 @@ "node": ">=12.10" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -8435,6 +8485,17 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -8442,6 +8503,11 @@ "dev": true, "license": "MIT" }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -8455,6 +8521,8 @@ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" @@ -8485,7 +8553,9 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/decompress-response": { "version": "6.0.0", @@ -8743,6 +8813,55 @@ "license": "MIT", "peer": true }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.4.11", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", @@ -8752,6 +8871,19 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -9448,6 +9580,8 @@ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -10622,6 +10756,8 @@ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.6.0" }, @@ -10629,6 +10765,40 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -10899,7 +11069,9 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/is-stream": { "version": "1.1.0", @@ -11067,6 +11239,8 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -11108,6 +11282,8 @@ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -11457,6 +11633,29 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/listr2": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.2.tgz", @@ -11964,6 +12163,17 @@ "node": ">=6" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -11993,7 +12203,9 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, - "license": "CC0-1.0" + "license": "CC0-1.0", + "optional": true, + "peer": true }, "node_modules/mem": { "version": "4.3.0", @@ -12659,6 +12871,17 @@ "node": ">=4" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -12968,6 +13191,8 @@ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "entities": "^8.0.0" }, @@ -13376,6 +13601,8 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -13750,7 +13977,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -14043,6 +14269,8 @@ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -14529,7 +14757,9 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/tailwind-merge": { "version": "3.6.0", @@ -14823,6 +15053,8 @@ "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tldts-core": "^7.4.2" }, @@ -14835,7 +15067,9 @@ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/tmp": { "version": "0.2.7", @@ -14876,6 +15110,8 @@ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -14889,6 +15125,8 @@ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -14963,12 +15201,19 @@ "node": ">=14.17" } }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==" + }, "node_modules/undici": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=20.18.1" } @@ -15408,6 +15653,8 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -15464,6 +15711,8 @@ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -15548,6 +15797,8 @@ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -15558,6 +15809,8 @@ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", @@ -15641,6 +15894,8 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -15660,7 +15915,9 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/y18n": { "version": "5.0.8", diff --git a/frontend/package.json b/frontend/package.json index a70a05a573..87b8037107 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,7 +45,6 @@ "@vitejs/plugin-react": "^6.0.2", "app-builder-lib": "^26.15.3", "electron": "^33.0.0", - "jsdom": "^29.1.1", "openapi-typescript": "^7.13.0", "playwright": "^1.60.0", "tailwindcss": "^4.3.0", @@ -67,10 +66,14 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^5.5.0", + "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.2.4", "electron-updater": "^6.8.9", + "linkedom": "^0.18.12", "lucide-react": "^1.17.0", + "marked": "^15.0.11", "openapi-fetch": "^0.17.0", "posthog-js": "^1.390.2", "radix-ui": "^1.5.0", diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 3bd45ec046..d1d6357ec2 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -613,6 +613,7 @@ export interface components { terminalHandleId?: string; /** Format: date-time */ updatedAt: string; + workspacePath?: string; }; DegradedProject: { id: string; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 835440a017..1b273be337 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -47,6 +47,7 @@ import { buildDaemonEnv, resolveShellEnv, type ShellRunner } from "./shared/shel import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { buildTelemetryBootstrap } from "./shared/telemetry"; import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host"; +import { MarkdownHost } from "./main/markdown-host"; import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link"; import { shouldLinkOnAttach } from "./main/daemon-owner"; import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state"; @@ -94,6 +95,8 @@ const RENDERER_SCHEME = "app"; const RENDERER_HOST = "renderer"; const RENDERER_ORIGIN = `${RENDERER_SCHEME}://${RENDERER_HOST}`; +const MD_PREVIEW_HOST = "md-preview"; + // The packaged renderer is served from a custom standard scheme, not file://. // A file:// page has the opaque "null" origin, which the daemon must never // trust (every sandboxed iframe on any website also presents "null"), so its @@ -109,26 +112,27 @@ protocol.registerSchemesAsPrivileged([ }, ]); -// Maps app://renderer/ to the built renderer in dist/. Paths without a -// file extension are client-side routes and fall back to index.html (SPA). -function registerRendererProtocol(): void { +let markdownHost: MarkdownHost | null = null; + +// Renderer protocol handler body. Maps app://renderer/ to the built +// SPA in dist/. Paths without a file extension fall back to index.html. +// Used directly by the consolidated app:// protocol handler below. +function registerRendererProtocolInner(request: Request): Promise | Response { + const url = new URL(request.url); + if (url.host !== RENDERER_HOST) { + return new Response("Not found", { status: 404 }); + } const distRoot = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); - protocol.handle(RENDERER_SCHEME, async (request) => { - const url = new URL(request.url); - if (url.host !== RENDERER_HOST) { - return new Response("Not found", { status: 404 }); - } - const resolved = path.resolve(path.join(distRoot, decodeURIComponent(url.pathname))); - if (resolved !== distRoot && !resolved.startsWith(distRoot + path.sep)) { - return new Response("Forbidden", { status: 403 }); - } - const target = path.extname(resolved) === "" ? path.join(distRoot, "index.html") : resolved; - try { - return await net.fetch(pathToFileURL(target).toString()); - } catch { - return new Response("Not found", { status: 404 }); - } - }); + const resolved = path.resolve(path.join(distRoot, decodeURIComponent(url.pathname))); + if (resolved !== distRoot && !resolved.startsWith(distRoot + path.sep)) { + return new Response("Forbidden", { status: 403 }); + } + const target = path.extname(resolved) === "" ? path.join(distRoot, "index.html") : resolved; + try { + return net.fetch(pathToFileURL(target).toString()); + } catch { + return new Response("Not found", { status: 404 }); + } } function rendererUrl(): string { @@ -222,6 +226,13 @@ function createWindow(): void { rendererOrigin: RENDERER_ORIGIN, }); + markdownHost?.dispose(); + markdownHost = new MarkdownHost(mainWindow); + + ipcMain.handle("browser:renderMarkdown", async (_event, request) => { + return await markdownHost!.render(request satisfies import("./shared/markdown-types").RenderMarkdownRequest); + }); + void mainWindow.loadURL(rendererUrl()); if (isDev && process.env.AO_OPEN_DEVTOOLS === "1") { @@ -233,6 +244,8 @@ function createWindow(): void { mainWindow.on("closed", () => { browserViewHost?.dispose(); browserViewHost = null; + markdownHost?.dispose(); + markdownHost = null; mainWindow = null; }); } @@ -688,6 +701,7 @@ async function startDaemonInner(startEpoch: number): Promise { portConfirmed = true; stopDiscovery(); setDaemonStatus({ state: "ready", port }); + markdownHost?.setDaemonPort(port); // Establish the OS-native liveness link unconditionally: this callback fires // only on the spawn path (we own this daemon). Holding the connection keeps @@ -978,7 +992,30 @@ app.whenReady().then(async () => { console.error("failed to write app-state marker:", err); } - registerRendererProtocol(); + // Single protocol handler for the app:// scheme. + // Routes app://renderer/* to the built SPA and app://md-preview/* + // to cached markdown preview HTML from MarkdownHost. + protocol.handle("app", (request) => { + const url = new URL(request.url); + if (url.host === MD_PREVIEW_HOST) { + const documentId = decodeURIComponent(url.pathname.replace(/^\//, "")); + const html = markdownHost?.getCachedHtml(documentId); + if (!html) { + return new Response("Markdown preview not found", { status: 404 }); + } + return new Response(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + }); + } + // All other app:// hosts fall through to the renderer (SPA) handler. + return registerRendererProtocolInner(request); + }); applyRuntimeAppIcon(); createWindow(); void startDaemon(); @@ -998,6 +1035,8 @@ app.whenReady().then(async () => { app.on("before-quit", () => { browserViewHost?.dispose(); browserViewHost = null; + markdownHost?.dispose(); + markdownHost = null; }); // Last resort: if the OS-native supervisor link is not actually connected diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts index a242c23abd..11d27aa5fc 100644 --- a/frontend/src/main/browser-view-host.test.ts +++ b/frontend/src/main/browser-view-host.test.ts @@ -86,8 +86,8 @@ describe("normalizeBrowserURL", () => { }); it("rejects privileged or unsupported schemes", () => { - expect(() => normalizeBrowserURL("app://renderer/index.html")).toThrow(/unsupported/i); expect(() => normalizeBrowserURL("javascript:alert(1)")).toThrow(/unsupported/i); + expect(() => normalizeBrowserURL("chrome://settings")).toThrow(/unsupported/i); }); }); @@ -99,6 +99,10 @@ describe("isAllowedBrowserURL", () => { it("still blocks the renderer's own http origin", () => { expect(isAllowedBrowserURL("http://localhost:5173/", "http://localhost:5173")).toBe(false); }); + + it("allows app://md-preview URLs as valid browser targets", () => { + expect(isAllowedBrowserURL("app://md-preview/md://sess/1")).toBe(true); + }); }); describe("browser:clear", () => { diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts index 193ddf47ba..38cc844ae7 100644 --- a/frontend/src/main/browser-view-host.ts +++ b/frontend/src/main/browser-view-host.ts @@ -87,7 +87,9 @@ type BrowserEntry = { const OFFSCREEN_BOUNDS: BrowserRect = { x: -10_000, y: -10_000, width: 0, height: 0 }; // ponytail: file:// allowed unsanitized; preview targets are agent-trusted for now -const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:"]); +// app: is needed for markdown previews served from app://md-preview/. +// The app://renderer origin is blocked via isAllowedBrowserURL's rendererOrigin check. +const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:", "app:"]); export function normalizeBrowserURL(input: string): URL { const raw = input.trim(); diff --git a/frontend/src/main/markdown-host.ts b/frontend/src/main/markdown-host.ts new file mode 100644 index 0000000000..0d898b7fd5 --- /dev/null +++ b/frontend/src/main/markdown-host.ts @@ -0,0 +1,365 @@ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { net } from "electron"; +import chokidar, { type FSWatcher } from "chokidar"; +import { renderMarkdown, extractTitle } from "./markdown-renderer"; +import type { + MarkdownDocument, + MarkdownSource, + RenderMarkdownRequest, + RenderMarkdownResponse, + MarkdownUpdateEvent, +} from "../shared/markdown-types"; +import { MarkdownIpcChannels } from "../shared/markdown-types"; + +type MainWindowLike = { + webContents: { send: (channel: string, ...args: unknown[]) => void }; +}; + +export function mdPreviewUrl(documentId: string): string { + return `app://md-preview/${encodeURIComponent(documentId)}`; +} + +export class MarkdownHost { + private mainWindow: MainWindowLike; + private documents = new Map(); + private seqCounter = 0; + private watchers = new Map(); + private docFiles = new Map>(); + private debounceTimers = new Map>(); + private disposed = false; + /** Set by main.ts once the daemon reports its bound port. */ + private daemonPort: number | undefined; + + constructor(mainWindow: MainWindowLike) { + this.mainWindow = mainWindow; + } + + setDaemonPort(port: number): void { + this.daemonPort = port; + } + + async render(request: RenderMarkdownRequest): Promise { + this.ensureNotDisposed(); + + let source = request.source; + + if (source.kind === "file") { + const content = await readFile(source.path, "utf-8"); + const doc = await this.createDocument(request.sessionId, source, content); + this.ensureWatcher(doc); + return docToResponse(doc); + } + + if (source.kind === "virtual") { + const doc = await this.createDocument(request.sessionId, source, source.content); + return docToResponse(doc); + } + + if (source.kind === "url") { + const localPath = resolveLocalPath(source.url, request.workspacePath); + if (localPath) { + const content = await readFile(localPath, "utf-8"); + const doc = await this.createDocument(request.sessionId, { kind: "file", path: localPath }, content); + this.ensureWatcher(doc); + return docToResponse(doc); + } + // Fallback: try the URL as a direct filesystem path. + // Catches edge cases like UNC paths on platforms where fileURLToPath + // produces a path that existsSync rejects, or paths the BrowserPanel + // normalizer didn't convert to file://. + const fsPath = tryLocalFile(source.url); + if (fsPath) { + const content = await readFile(fsPath, "utf-8"); + const doc = await this.createDocument(request.sessionId, { kind: "file", path: fsPath }, content); + this.ensureWatcher(doc); + return docToResponse(doc); + } + // Don't let file:// URLs reach fetch — it only supports http:/https:. + if (source.url.startsWith("file://")) { + throw new Error(`File not found: ${source.url}`); + } + const response = await fetch(source.url); + if (!response.ok) { + throw new Error(`Failed to fetch markdown from ${source.url}: ${response.status}`); + } + const content = await response.text(); + const doc = await this.createDocument(request.sessionId, source, content); + return docToResponse(doc); + } + + throw new Error(`Unsupported markdown source kind: ${(source as { kind: string }).kind}`); + } + + destroy(documentId: string): void { + this.documents.delete(documentId); + this.removeFileWatchersForDoc(documentId); + } + + destroySession(sessionId: string): void { + for (const [docId, doc] of this.documents) { + if (doc.sessionId === sessionId) { + this.destroy(docId); + } + } + } + + dispose(): void { + this.disposed = true; + for (const watcher of this.watchers.values()) { + watcher.close(); + } + this.watchers.clear(); + this.docFiles.clear(); + this.documents.clear(); + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + } + + getCachedHtml(documentId: string): string | null { + const doc = this.documents.get(documentId); + return doc?.renderedHtml ?? null; + } + + private ensureNotDisposed(): void { + if (this.disposed) throw new Error("MarkdownHost has been disposed"); + } + + private async createDocument( + sessionId: string, + source: MarkdownSource, + content: string, + ): Promise { + const renderedHtml = renderMarkdown(content); + const title = extractTitle(renderedHtml) + ?? (source.kind === "file" ? path.basename(source.path) : "Markdown Preview"); + + const sourceKey = sourceKeyOf(source); + for (const doc of this.documents.values()) { + if (doc.sessionId === sessionId && sourceKeyOf(doc.source) === sourceKey) { + doc.renderedHtml = renderedHtml; + doc.title = title; + doc.revision++; + doc.updatedAt = Date.now(); + this.documents.set(doc.id, doc); + this.mainWindow.webContents.send(MarkdownIpcChannels.stateChanged, { + documentId: doc.id, + revision: doc.revision, + needsReload: true, + title, + } satisfies MarkdownUpdateEvent); + return doc; + } + } + + this.seqCounter++; + const id = `md://${sessionId}/${this.seqCounter}`; + const now = Date.now(); + const doc: MarkdownDocument = { + id, + sessionId, + source, + renderedHtml, + title, + revision: 1, + createdAt: now, + updatedAt: now, + }; + this.documents.set(id, doc); + return doc; + } + + private ensureWatcher(doc: MarkdownDocument): void { + if (doc.source.kind !== "file") return; + + const filePath = path.resolve(doc.source.path); + + let files = this.docFiles.get(doc.id); + if (!files) { + files = new Set(); + this.docFiles.set(doc.id, files); + } + if (files.has(filePath)) return; + files.add(filePath); + + if (this.watchers.has(filePath)) return; + + const watcher = chokidar.watch(filePath, { + awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }, + ignoreInitial: true, + followSymlinks: false, + }); + + watcher.on("change", () => this.onFileChanged(filePath)); + watcher.on("unlink", () => this.onFileDeleted(filePath)); + watcher.on("error", (err: unknown) => { + console.error(`[md-host] Watcher error for ${filePath}:`, err); + }); + + this.watchers.set(filePath, watcher); + } + + private onFileChanged(filePath: string): void { + const existing = this.debounceTimers.get(filePath); + if (existing) clearTimeout(existing); + + const timer = setTimeout(async () => { + this.debounceTimers.delete(filePath); + + for (const [docId, files] of this.docFiles) { + if (!files.has(filePath)) continue; + + const doc = this.documents.get(docId); + if (!doc) continue; + + try { + const content = await readFile(filePath, "utf-8"); + const renderedHtml = renderMarkdown(content, doc.title); + const title = extractTitle(renderedHtml) ?? doc.title; + doc.renderedHtml = renderedHtml; + doc.title = title; + doc.revision++; + doc.updatedAt = Date.now(); + this.documents.set(docId, doc); + + this.mainWindow.webContents.send(MarkdownIpcChannels.fileChanged, { + documentId: docId, + revision: doc.revision, + needsReload: true, + title, + } satisfies MarkdownUpdateEvent); + } catch (err) { + console.error(`[md-host] Failed to re-render ${filePath}:`, err); + } + } + }, 300); + + this.debounceTimers.set(filePath, timer); + } + + private onFileDeleted(filePath: string): void { + const timer = this.debounceTimers.get(filePath); + if (timer) clearTimeout(timer); + this.debounceTimers.delete(filePath); + + // Collect affected session IDs before removing the documents. + const sessionIds = new Set(); + for (const [docId, files] of this.docFiles) { + if (!files.has(filePath)) continue; + const doc = this.documents.get(docId); + if (doc) sessionIds.add(doc.sessionId); + this.documents.delete(docId); + } + this.docFiles.delete(filePath); + + const watcher = this.watchers.get(filePath); + if (watcher) { + watcher.close(); + this.watchers.delete(filePath); + } + + // Tell the daemon to revert the preview for each affected session. The + // daemon's clearPreview handler autodetects index.html if it exists, or + // clears to blank — no error/warning page is shown to the user. + if (this.daemonPort === undefined) return; + for (const sessionId of sessionIds) { + const url = `http://127.0.0.1:${this.daemonPort}/api/v1/sessions/${encodeURIComponent(sessionId)}/preview`; + net.fetch(url, { method: "DELETE" }).catch((err: unknown) => { + console.error(`[md-host] Failed to revert preview for session ${sessionId}:`, err); + }); + } + } + + private removeFileWatchersForDoc(documentId: string): void { + const files = this.docFiles.get(documentId); + if (!files) return; + + for (const filePath of files) { + const watcher = this.watchers.get(filePath); + if (watcher) { + watcher.close(); + this.watchers.delete(filePath); + } + const timer = this.debounceTimers.get(filePath); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(filePath); + } + } + this.docFiles.delete(documentId); + } +} + +function docToResponse(doc: MarkdownDocument): RenderMarkdownResponse { + return { + documentId: doc.id, + url: `app://md-preview/${encodeURIComponent(doc.id)}`, + title: doc.title, + revision: doc.revision, + }; +} + +function sourceKeyOf(source: MarkdownSource): string { + switch (source.kind) { + case "file": + return `file:${path.resolve(source.path)}`; + case "virtual": + return `vhash:${hashCode(source.content)}`; + case "url": + return `url:${source.url}`; + } +} + +function hashCode(s: string): string { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + } + return h.toString(36); +} + +const DAEMON_PROXY_RE = /\/api\/v1\/sessions\/[^/]+\/preview\/files\/(.+)$/i; + +function parseDaemonProxyEntry(url: string): string | null { + const m = DAEMON_PROXY_RE.exec(url); + if (!m) return null; + try { + return decodeURIComponent(m[1]); + } catch { + return null; + } +} + +function tryLocalFile(url: string): string | null { + // Skip network URLs — let the fetch path handle those. + if (/^https?:\/\//i.test(url)) return null; + // Normalize backslashes to forward slashes and strip any file:// prefix. + const normalized = url.replace(/\\/g, "/").replace(/^file:\/\//i, ""); + if (existsSync(normalized)) return normalized; + return null; +} + +function resolveLocalPath(url: string, workspacePath?: string): string | null { + let p: string; + if (url.startsWith("file://")) { + p = fileURLToPath(url); + } else if (url.startsWith("/")) { + p = url; + } else if (workspacePath) { + const entry = parseDaemonProxyEntry(url); + if (!entry) return null; + p = path.join(workspacePath, entry); + // Prevent directory traversal outside the workspace path. + if (!p.startsWith(workspacePath + path.sep) && p !== workspacePath) return null; + } else { + return null; + } + if (existsSync(p) && !p.endsWith("/")) { + return p; + } + return null; +} diff --git a/frontend/src/main/markdown-renderer.ts b/frontend/src/main/markdown-renderer.ts new file mode 100644 index 0000000000..31c7c8ab55 --- /dev/null +++ b/frontend/src/main/markdown-renderer.ts @@ -0,0 +1,253 @@ +/** + * Markdown rendering pipeline. + * + * Converts raw markdown source into a safely-sanitised HTML page + * wrapped in a strict CSP template ready to serve via the + * `app://md-preview/` custom protocol. + * + * Uses `marked` for parsing and `DOMPurify` running on a window + * created by `linkedom` (a lightweight DOM implementation) for HTML + * sanitisation. The output contains zero JavaScript and carries a + * `script-src 'none'` policy so that even a sanitisation bypass + * cannot execute code in the preview view. + */ + +import { marked } from "marked"; +import createDOMPurify from "dompurify"; +import { parseHTML } from "linkedom"; + +// --------------------------------------------------------------------------- +// One-off DOMPurify instance backed by a linkedom window. +// --------------------------------------------------------------------------- +const { window } = parseHTML(""); +const purify = createDOMPurify(window); + +// --------------------------------------------------------------------------- +// CSP / HTML template for every markdown preview page. +// --------------------------------------------------------------------------- + +const PREVIEW_CSP = [ + "default-src 'none'", + "style-src 'unsafe-inline'", + "img-src 'self' data: file: http: https:", + "font-src 'self' data:", + "script-src 'none'", + "frame-src 'none'", + "base-uri 'none'", + "form-action 'none'", +].join("; "); + +const HTML_TEMPLATE = ` + + + + + +{{TITLE}} + + + + + +
+{{BODY}} +
+ +`; + +// --------------------------------------------------------------------------- +// Allowed tags / attributes for the sanitised output. +// --------------------------------------------------------------------------- + +const ALLOWED_TAGS = [ + "h1", "h2", "h3", "h4", "h5", "h6", + "p", "br", "hr", + "ul", "ol", "li", + "pre", "code", "blockquote", + "strong", "em", "del", "ins", "sub", "sup", + "a", "img", + "table", "thead", "tbody", "tfoot", "tr", "th", "td", + "div", "span", + "dl", "dt", "dd", + "abbr", "address", +]; + +const ALLOWED_ATTR = [ + "href", "src", "alt", "title", + "target", "rel", + "id", "class", + "colspan", "rowspan", +]; + +// --------------------------------------------------------------------------- +// Public render function +// --------------------------------------------------------------------------- + +/** + * Render raw markdown to a self-contained, sanitised HTML page. + * + * The output is safe to serve to any WebContentsView: + * - All HTML tags not in the allowlist are stripped. + * - All attributes not in the allowlist are stripped. + * - No JavaScript is present. + * - The page carries a strict CSP. + * - The dark-mode styles follow the system preference automatically. + * + * @param source — Raw markdown text. + * @param title — Optional page title (falls back to "Markdown Preview"). + * @returns A complete HTML document string. + */ +export function renderMarkdown(source: string, title?: string): string { + // 1. Parse markdown to raw HTML. + const rawHtml = marked.parse(source, { async: false }) as string; + + // 2. Sanitise with DOMPurify — strips everything not in the allowlist. + const safeHtml = purify.sanitize(rawHtml, { + ALLOWED_TAGS, + ALLOWED_ATTR, + ALLOW_DATA_ATTR: false, + }); + + // 3. Wrap in the template. + const pageTitle = title?.trim() || "Markdown Preview"; + return HTML_TEMPLATE + .replace("{{CSP}}", PREVIEW_CSP) + .replace("{{TITLE}}", escapeHtml(pageTitle)) + .replace("{{BODY}}", safeHtml); +} + +// --------------------------------------------------------------------------- +// Minimal HTML-escaping for the element. +// --------------------------------------------------------------------------- + +const HTML_ENTITY_MAP: Record<string, string> = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (ch) => HTML_ENTITY_MAP[ch] ?? ch); +} + +// --------------------------------------------------------------------------- +// Extract the first H1 from raw HTML for use as a page title. +// --------------------------------------------------------------------------- + +const H1_RE = /<h1[^>]*>([\s\S]*?)<\/h1>/i; +const TAG_RE = /<[^>]*>/g; + +/** + * Try to extract a human-readable title from rendered HTML. + * Returns `undefined` when no H1 is present. + */ +export function extractTitle(renderedHtml: string): string | undefined { + const m = H1_RE.exec(renderedHtml); + if (!m) return undefined; + return m[1].replace(TAG_RE, "").trim(); +} diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index 2284e6c449..cf67cb45e9 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -4,6 +4,8 @@ import type { DaemonStatus } from "./shared/daemon-status"; import type { TelemetryBootstrap } from "./shared/telemetry"; import type { MigrationState } from "./main/app-state"; import type { UpdateSettings, UpdateStatus } from "./main/update-settings"; +import type { MarkdownSource, RenderMarkdownRequest, RenderMarkdownResponse, MarkdownUpdateEvent } from "./shared/markdown-types"; +import { MarkdownIpcChannels } from "./shared/markdown-types"; export type BrowserBoundsInput = { viewId: string; @@ -58,6 +60,19 @@ const api = { ipcRenderer.off("browser:navState", wrapped); }; }, + renderMarkdown: (sessionId: string, source: MarkdownSource, workspacePath?: string) => + ipcRenderer.invoke("browser:renderMarkdown", { + sessionId, + source, + workspacePath, + } satisfies RenderMarkdownRequest) as Promise<RenderMarkdownResponse>, + onMarkdownFileChanged: (listener: (event: MarkdownUpdateEvent) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, event: MarkdownUpdateEvent) => listener(event); + ipcRenderer.on(MarkdownIpcChannels.fileChanged, wrapped); + return () => { + ipcRenderer.off(MarkdownIpcChannels.fileChanged, wrapped); + }; + }, }, notifications: { show: (notification: { id: string; title: string; body?: string }) => diff --git a/frontend/src/renderer/components/BrowserPanel.tsx b/frontend/src/renderer/components/BrowserPanel.tsx index b6d29ce462..02544216ae 100644 --- a/frontend/src/renderer/components/BrowserPanel.tsx +++ b/frontend/src/renderer/components/BrowserPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { ArrowLeft, ArrowRight, Globe2, Maximize2, Minimize2, RefreshCw, X } from "lucide-react"; import { useBrowserView, type BrowserViewModel } from "../hooks/useBrowserView"; import type { WorkspaceSession } from "../types/workspace"; +import { MARKDOWN_FILE_RE } from "../../shared/markdown-types"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -19,6 +20,7 @@ export function BrowserPanel({ session, active, poppedOut, onTogglePopOut }: Bro poppedOut, previewUrl: session.previewUrl, previewRevision: session.previewRevision, + workspacePath: session.workspacePath, }); return ( <BrowserPanelView @@ -45,8 +47,26 @@ export function BrowserPanelView({ const submit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); - const nextURL = urlInput.trim(); - if (nextURL) void navigate(nextURL); + const raw = urlInput.trim(); + if (!raw) return; + // Normalise bare filesystem paths to file:// so that MarkdownHost can + // detect them as local files and set up file watching. + let nextURL = raw; + if (raw.startsWith("\\\\") || raw.startsWith("//")) { + // UNC path: \\host\share\path → file:////host/share/path + nextURL = `file://${raw.replace(/\\/g, "/")}`; + } else if (raw.startsWith("/") || raw.startsWith("\\")) { + // Unix absolute or backslash-prefixed: /path → file:///path + nextURL = `file://${raw.replace(/\\/g, "/")}`; + } else if (/^[a-zA-Z]:[\\/]/.test(raw)) { + // Windows drive letter: C:\path → file:///C:/path + nextURL = `file:///${raw.replace(/\\/g, "/")}`; + } + if (MARKDOWN_FILE_RE.test(nextURL)) { + void browserView.renderMarkdown({ kind: "url", url: nextURL }); + } else { + void navigate(nextURL); + } }; return ( diff --git a/frontend/src/renderer/components/SessionView.tsx b/frontend/src/renderer/components/SessionView.tsx index 69b320299f..5ccd9b1d5d 100644 --- a/frontend/src/renderer/components/SessionView.tsx +++ b/frontend/src/renderer/components/SessionView.tsx @@ -55,6 +55,7 @@ export function SessionView({ sessionId }: SessionViewProps) { const hasInspector = !isOrchestrator; const previewUrl = session?.previewUrl?.trim() || undefined; const previewRevision = session?.previewRevision; + const workspacePath = session?.workspacePath || undefined; const revealedPreviewRef = useRef<number | null>(null); const browserView = useBrowserView({ sessionId, @@ -63,6 +64,7 @@ export function SessionView({ sessionId }: SessionViewProps) { terminated: session?.status === "terminated", previewUrl, previewRevision, + workspacePath, }); useEffect(() => { diff --git a/frontend/src/renderer/hooks/useBrowserView.test.tsx b/frontend/src/renderer/hooks/useBrowserView.test.tsx index b83568eee4..350806f2b2 100644 --- a/frontend/src/renderer/hooks/useBrowserView.test.tsx +++ b/frontend/src/renderer/hooks/useBrowserView.test.tsx @@ -55,6 +55,13 @@ function setupBridge() { listeners.add(listener); return () => listeners.delete(listener); }), + renderMarkdown: vi.fn(async () => ({ + documentId: "md://test-session/1", + url: "app://md-preview/md://test-session/1", + title: "Test", + revision: 1, + })), + onMarkdownFileChanged: vi.fn(() => () => undefined), emit(state: BrowserNavState) { listeners.forEach((listener) => listener(state)); }, diff --git a/frontend/src/renderer/hooks/useBrowserView.ts b/frontend/src/renderer/hooks/useBrowserView.ts index e5ba98ef49..688109793e 100644 --- a/frontend/src/renderer/hooks/useBrowserView.ts +++ b/frontend/src/renderer/hooks/useBrowserView.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { BrowserNavState, BrowserRect } from "../../main/browser-view-host"; +import type { MarkdownSource, RenderMarkdownResponse } from "../../shared/markdown-types"; +import { MARKDOWN_FILE_RE } from "../../shared/markdown-types"; export type { BrowserNavState }; @@ -25,6 +27,8 @@ type UseBrowserViewOptions = { * unrelated session update, which leave it unchanged, are ignored). */ previewRevision?: number; + /** Session worktree path, used to resolve daemon-proxied markdown URLs to local files for watcher setup. */ + workspacePath?: string; }; export type BrowserViewModel = { @@ -32,6 +36,8 @@ export type BrowserViewModel = { navState: BrowserNavState; slotRef: (node: HTMLDivElement | null) => void; navigate: (url: string) => Promise<void>; + /** Render markdown from a source and navigate the view to the preview. */ + renderMarkdown: (source: MarkdownSource) => Promise<RenderMarkdownResponse>; goBack: () => Promise<void>; goForward: () => Promise<void>; reload: () => Promise<void>; @@ -77,6 +83,7 @@ export function useBrowserView({ terminated, previewUrl, previewRevision, + workspacePath, }: UseBrowserViewOptions): BrowserViewModel { const [viewId, setViewId] = useState(""); const [navState, setNavState] = useState<BrowserNavState>(EMPTY_NAV_STATE); @@ -235,6 +242,28 @@ export function useBrowserView({ const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]); + const currentDocIdRef = useRef(""); + + const renderMarkdown = useCallback( + async (source: MarkdownSource): Promise<RenderMarkdownResponse> => { + const result = await window.ao!.browser.renderMarkdown(sessionId, source, workspacePath); + currentDocIdRef.current = result.documentId; + await window.ao!.browser.navigate({ viewId: viewIdRef.current, url: result.url }); + return result; + }, + [sessionId], + ); + + // Auto-refresh the browser view when a file-backed markdown preview changes. + useEffect(() => { + return window.ao?.browser.onMarkdownFileChanged((event) => { + if (event.documentId !== currentDocIdRef.current) return; + const id = viewIdRef.current; + if (!id) return; + void window.ao!.browser.reload(id); + }); + }, []); + // When the session is terminated, clear the view and stop reacting to // daemon-driven preview changes so stale content does not remain visible. useEffect(() => { @@ -245,6 +274,7 @@ export function useBrowserView({ // Drive the view from the daemon-set preview target. Current daemons key // this on previewRevision (bumped on every `ao preview` call); older daemons // did not send it, so fall back to URL changes for compatibility. + // When the target looks like a markdown file, route through renderMarkdown. useEffect(() => { if (!viewId || terminated) return; const target = previewUrl?.trim() ?? ""; @@ -254,11 +284,15 @@ export function useBrowserView({ if (revision !== null && previous?.revision === revision) return; previewTriggerRef.current = { revision, target }; if (target) { - void navigate(target); + if (MARKDOWN_FILE_RE.test(target)) { + void renderMarkdown({ kind: "url", url: target }); + } else { + void navigate(target); + } } else if ((revision !== null && revision > 0) || previous?.target) { void clear(); } - }, [clear, navigate, previewRevision, previewUrl, viewId]); + }, [clear, navigate, renderMarkdown, previewRevision, previewUrl, viewId]); const destroy = useCallback(() => { const id = viewIdRef.current; @@ -273,6 +307,7 @@ export function useBrowserView({ navState, slotRef, navigate, + renderMarkdown, goBack: () => withView((id) => window.ao!.browser.goBack(id)), goForward: () => withView((id) => window.ao!.browser.goForward(id)), reload: () => withView((id) => window.ao!.browser.reload(id)), diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 479323c78a..238682f1ff 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -60,6 +60,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> { status: toSessionStatus(session.status, session.isTerminated), createdAt: session.createdAt, updatedAt: session.updatedAt, + workspacePath: session.workspacePath, activity: toSessionActivity(session.activity), previewUrl: session.previewUrl, previewRevision: session.previewRevision, diff --git a/frontend/src/renderer/lib/bridge.ts b/frontend/src/renderer/lib/bridge.ts index 01f15a503c..13ee77c720 100644 --- a/frontend/src/renderer/lib/bridge.ts +++ b/frontend/src/renderer/lib/bridge.ts @@ -1,4 +1,5 @@ import type { AoBridge } from "../../preload"; +import type { RenderMarkdownResponse } from "../../shared/markdown-types"; export const aoBridge: AoBridge = window.ao ?? @@ -87,6 +88,13 @@ export const aoBridge: AoBridge = }), destroy: () => undefined, onNavState: () => () => undefined, + renderMarkdown: async (): Promise<RenderMarkdownResponse> => ({ + documentId: "md://preview/1", + url: "app://md-preview/md://preview/1", + title: "Preview", + revision: 1, + }), + onMarkdownFileChanged: () => () => undefined, }, notifications: { show: async () => undefined, diff --git a/frontend/src/renderer/routeTree.gen.ts b/frontend/src/renderer/routeTree.gen.ts index 26cb7f148f..5add28aa8e 100644 --- a/frontend/src/renderer/routeTree.gen.ts +++ b/frontend/src/renderer/routeTree.gen.ts @@ -8,204 +8,209 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as ShellRouteImport } from "./routes/_shell"; -import { Route as ShellIndexRouteImport } from "./routes/_shell.index"; -import { Route as ShellSettingsRouteImport } from "./routes/_shell.settings"; -import { Route as ShellPrsRouteImport } from "./routes/_shell.prs"; -import { Route as ShellSessionsSessionIdRouteImport } from "./routes/_shell.sessions.$sessionId"; -import { Route as ShellProjectsProjectIdRouteImport } from "./routes/_shell.projects.$projectId"; -import { Route as ShellProjectsProjectIdSettingsRouteImport } from "./routes/_shell.projects.$projectId_.settings"; -import { Route as ShellProjectsProjectIdSessionsSessionIdRouteImport } from "./routes/_shell.projects.$projectId_.sessions.$sessionId"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as ShellRouteImport } from './routes/_shell' +import { Route as ShellIndexRouteImport } from './routes/_shell.index' +import { Route as ShellSettingsRouteImport } from './routes/_shell.settings' +import { Route as ShellPrsRouteImport } from './routes/_shell.prs' +import { Route as ShellSessionsSessionIdRouteImport } from './routes/_shell.sessions.$sessionId' +import { Route as ShellProjectsProjectIdRouteImport } from './routes/_shell.projects.$projectId' +import { Route as ShellProjectsProjectIdSettingsRouteImport } from './routes/_shell.projects.$projectId_.settings' +import { Route as ShellProjectsProjectIdSessionsSessionIdRouteImport } from './routes/_shell.projects.$projectId_.sessions.$sessionId' const ShellRoute = ShellRouteImport.update({ - id: "/_shell", - getParentRoute: () => rootRouteImport, -} as any); + id: '/_shell', + getParentRoute: () => rootRouteImport, +} as any) const ShellIndexRoute = ShellIndexRouteImport.update({ - id: "/", - path: "/", - getParentRoute: () => ShellRoute, -} as any); + id: '/', + path: '/', + getParentRoute: () => ShellRoute, +} as any) const ShellSettingsRoute = ShellSettingsRouteImport.update({ - id: "/settings", - path: "/settings", - getParentRoute: () => ShellRoute, -} as any); + id: '/settings', + path: '/settings', + getParentRoute: () => ShellRoute, +} as any) const ShellPrsRoute = ShellPrsRouteImport.update({ - id: "/prs", - path: "/prs", - getParentRoute: () => ShellRoute, -} as any); + id: '/prs', + path: '/prs', + getParentRoute: () => ShellRoute, +} as any) const ShellSessionsSessionIdRoute = ShellSessionsSessionIdRouteImport.update({ - id: "/sessions/$sessionId", - path: "/sessions/$sessionId", - getParentRoute: () => ShellRoute, -} as any); + id: '/sessions/$sessionId', + path: '/sessions/$sessionId', + getParentRoute: () => ShellRoute, +} as any) const ShellProjectsProjectIdRoute = ShellProjectsProjectIdRouteImport.update({ - id: "/projects/$projectId", - path: "/projects/$projectId", - getParentRoute: () => ShellRoute, -} as any); -const ShellProjectsProjectIdSettingsRoute = ShellProjectsProjectIdSettingsRouteImport.update({ - id: "/projects/$projectId_/settings", - path: "/projects/$projectId/settings", - getParentRoute: () => ShellRoute, -} as any); -const ShellProjectsProjectIdSessionsSessionIdRoute = ShellProjectsProjectIdSessionsSessionIdRouteImport.update({ - id: "/projects/$projectId_/sessions/$sessionId", - path: "/projects/$projectId/sessions/$sessionId", - getParentRoute: () => ShellRoute, -} as any); + id: '/projects/$projectId', + path: '/projects/$projectId', + getParentRoute: () => ShellRoute, +} as any) +const ShellProjectsProjectIdSettingsRoute = + ShellProjectsProjectIdSettingsRouteImport.update({ + id: '/projects/$projectId_/settings', + path: '/projects/$projectId/settings', + getParentRoute: () => ShellRoute, + } as any) +const ShellProjectsProjectIdSessionsSessionIdRoute = + ShellProjectsProjectIdSessionsSessionIdRouteImport.update({ + id: '/projects/$projectId_/sessions/$sessionId', + path: '/projects/$projectId/sessions/$sessionId', + getParentRoute: () => ShellRoute, + } as any) export interface FileRoutesByFullPath { - "/": typeof ShellIndexRoute; - "/prs": typeof ShellPrsRoute; - "/settings": typeof ShellSettingsRoute; - "/projects/$projectId": typeof ShellProjectsProjectIdRoute; - "/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; - "/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute; - "/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute; + '/': typeof ShellIndexRoute + '/prs': typeof ShellPrsRoute + '/settings': typeof ShellSettingsRoute + '/projects/$projectId': typeof ShellProjectsProjectIdRoute + '/sessions/$sessionId': typeof ShellSessionsSessionIdRoute + '/projects/$projectId/settings': typeof ShellProjectsProjectIdSettingsRoute + '/projects/$projectId/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute } export interface FileRoutesByTo { - "/prs": typeof ShellPrsRoute; - "/settings": typeof ShellSettingsRoute; - "/": typeof ShellIndexRoute; - "/projects/$projectId": typeof ShellProjectsProjectIdRoute; - "/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; - "/projects/$projectId/settings": typeof ShellProjectsProjectIdSettingsRoute; - "/projects/$projectId/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute; + '/prs': typeof ShellPrsRoute + '/settings': typeof ShellSettingsRoute + '/': typeof ShellIndexRoute + '/projects/$projectId': typeof ShellProjectsProjectIdRoute + '/sessions/$sessionId': typeof ShellSessionsSessionIdRoute + '/projects/$projectId/settings': typeof ShellProjectsProjectIdSettingsRoute + '/projects/$projectId/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/_shell": typeof ShellRouteWithChildren; - "/_shell/prs": typeof ShellPrsRoute; - "/_shell/settings": typeof ShellSettingsRoute; - "/_shell/": typeof ShellIndexRoute; - "/_shell/projects/$projectId": typeof ShellProjectsProjectIdRoute; - "/_shell/sessions/$sessionId": typeof ShellSessionsSessionIdRoute; - "/_shell/projects/$projectId_/settings": typeof ShellProjectsProjectIdSettingsRoute; - "/_shell/projects/$projectId_/sessions/$sessionId": typeof ShellProjectsProjectIdSessionsSessionIdRoute; + __root__: typeof rootRouteImport + '/_shell': typeof ShellRouteWithChildren + '/_shell/prs': typeof ShellPrsRoute + '/_shell/settings': typeof ShellSettingsRoute + '/_shell/': typeof ShellIndexRoute + '/_shell/projects/$projectId': typeof ShellProjectsProjectIdRoute + '/_shell/sessions/$sessionId': typeof ShellSessionsSessionIdRoute + '/_shell/projects/$projectId_/settings': typeof ShellProjectsProjectIdSettingsRoute + '/_shell/projects/$projectId_/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: - | "/" - | "/prs" - | "/settings" - | "/projects/$projectId" - | "/sessions/$sessionId" - | "/projects/$projectId/settings" - | "/projects/$projectId/sessions/$sessionId"; - fileRoutesByTo: FileRoutesByTo; - to: - | "/prs" - | "/settings" - | "/" - | "/projects/$projectId" - | "/sessions/$sessionId" - | "/projects/$projectId/settings" - | "/projects/$projectId/sessions/$sessionId"; - id: - | "__root__" - | "/_shell" - | "/_shell/prs" - | "/_shell/settings" - | "/_shell/" - | "/_shell/projects/$projectId" - | "/_shell/sessions/$sessionId" - | "/_shell/projects/$projectId_/settings" - | "/_shell/projects/$projectId_/sessions/$sessionId"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/prs' + | '/settings' + | '/projects/$projectId' + | '/sessions/$sessionId' + | '/projects/$projectId/settings' + | '/projects/$projectId/sessions/$sessionId' + fileRoutesByTo: FileRoutesByTo + to: + | '/prs' + | '/settings' + | '/' + | '/projects/$projectId' + | '/sessions/$sessionId' + | '/projects/$projectId/settings' + | '/projects/$projectId/sessions/$sessionId' + id: + | '__root__' + | '/_shell' + | '/_shell/prs' + | '/_shell/settings' + | '/_shell/' + | '/_shell/projects/$projectId' + | '/_shell/sessions/$sessionId' + | '/_shell/projects/$projectId_/settings' + | '/_shell/projects/$projectId_/sessions/$sessionId' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - ShellRoute: typeof ShellRouteWithChildren; + ShellRoute: typeof ShellRouteWithChildren } -declare module "@tanstack/react-router" { - interface FileRoutesByPath { - "/_shell": { - id: "/_shell"; - path: ""; - fullPath: "/"; - preLoaderRoute: typeof ShellRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/_shell/": { - id: "/_shell/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof ShellIndexRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/settings": { - id: "/_shell/settings"; - path: "/settings"; - fullPath: "/settings"; - preLoaderRoute: typeof ShellSettingsRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/prs": { - id: "/_shell/prs"; - path: "/prs"; - fullPath: "/prs"; - preLoaderRoute: typeof ShellPrsRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/sessions/$sessionId": { - id: "/_shell/sessions/$sessionId"; - path: "/sessions/$sessionId"; - fullPath: "/sessions/$sessionId"; - preLoaderRoute: typeof ShellSessionsSessionIdRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/projects/$projectId": { - id: "/_shell/projects/$projectId"; - path: "/projects/$projectId"; - fullPath: "/projects/$projectId"; - preLoaderRoute: typeof ShellProjectsProjectIdRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/projects/$projectId_/settings": { - id: "/_shell/projects/$projectId_/settings"; - path: "/projects/$projectId/settings"; - fullPath: "/projects/$projectId/settings"; - preLoaderRoute: typeof ShellProjectsProjectIdSettingsRouteImport; - parentRoute: typeof ShellRoute; - }; - "/_shell/projects/$projectId_/sessions/$sessionId": { - id: "/_shell/projects/$projectId_/sessions/$sessionId"; - path: "/projects/$projectId/sessions/$sessionId"; - fullPath: "/projects/$projectId/sessions/$sessionId"; - preLoaderRoute: typeof ShellProjectsProjectIdSessionsSessionIdRouteImport; - parentRoute: typeof ShellRoute; - }; - } +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_shell': { + id: '/_shell' + path: '' + fullPath: '/' + preLoaderRoute: typeof ShellRouteImport + parentRoute: typeof rootRouteImport + } + '/_shell/': { + id: '/_shell/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof ShellIndexRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/settings': { + id: '/_shell/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof ShellSettingsRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/prs': { + id: '/_shell/prs' + path: '/prs' + fullPath: '/prs' + preLoaderRoute: typeof ShellPrsRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/sessions/$sessionId': { + id: '/_shell/sessions/$sessionId' + path: '/sessions/$sessionId' + fullPath: '/sessions/$sessionId' + preLoaderRoute: typeof ShellSessionsSessionIdRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/projects/$projectId': { + id: '/_shell/projects/$projectId' + path: '/projects/$projectId' + fullPath: '/projects/$projectId' + preLoaderRoute: typeof ShellProjectsProjectIdRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/projects/$projectId_/settings': { + id: '/_shell/projects/$projectId_/settings' + path: '/projects/$projectId/settings' + fullPath: '/projects/$projectId/settings' + preLoaderRoute: typeof ShellProjectsProjectIdSettingsRouteImport + parentRoute: typeof ShellRoute + } + '/_shell/projects/$projectId_/sessions/$sessionId': { + id: '/_shell/projects/$projectId_/sessions/$sessionId' + path: '/projects/$projectId/sessions/$sessionId' + fullPath: '/projects/$projectId/sessions/$sessionId' + preLoaderRoute: typeof ShellProjectsProjectIdSessionsSessionIdRouteImport + parentRoute: typeof ShellRoute + } + } } interface ShellRouteChildren { - ShellPrsRoute: typeof ShellPrsRoute; - ShellSettingsRoute: typeof ShellSettingsRoute; - ShellIndexRoute: typeof ShellIndexRoute; - ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute; - ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute; - ShellProjectsProjectIdSettingsRoute: typeof ShellProjectsProjectIdSettingsRoute; - ShellProjectsProjectIdSessionsSessionIdRoute: typeof ShellProjectsProjectIdSessionsSessionIdRoute; + ShellPrsRoute: typeof ShellPrsRoute + ShellSettingsRoute: typeof ShellSettingsRoute + ShellIndexRoute: typeof ShellIndexRoute + ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute + ShellSessionsSessionIdRoute: typeof ShellSessionsSessionIdRoute + ShellProjectsProjectIdSettingsRoute: typeof ShellProjectsProjectIdSettingsRoute + ShellProjectsProjectIdSessionsSessionIdRoute: typeof ShellProjectsProjectIdSessionsSessionIdRoute } const ShellRouteChildren: ShellRouteChildren = { - ShellPrsRoute: ShellPrsRoute, - ShellSettingsRoute: ShellSettingsRoute, - ShellIndexRoute: ShellIndexRoute, - ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute, - ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute, - ShellProjectsProjectIdSettingsRoute: ShellProjectsProjectIdSettingsRoute, - ShellProjectsProjectIdSessionsSessionIdRoute: ShellProjectsProjectIdSessionsSessionIdRoute, -}; + ShellPrsRoute: ShellPrsRoute, + ShellSettingsRoute: ShellSettingsRoute, + ShellIndexRoute: ShellIndexRoute, + ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute, + ShellSessionsSessionIdRoute: ShellSessionsSessionIdRoute, + ShellProjectsProjectIdSettingsRoute: ShellProjectsProjectIdSettingsRoute, + ShellProjectsProjectIdSessionsSessionIdRoute: + ShellProjectsProjectIdSessionsSessionIdRoute, +} -const ShellRouteWithChildren = ShellRoute._addFileChildren(ShellRouteChildren); +const ShellRouteWithChildren = ShellRoute._addFileChildren(ShellRouteChildren) const rootRouteChildren: RootRouteChildren = { - ShellRoute: ShellRouteWithChildren, -}; -export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>(); + ShellRoute: ShellRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes<FileRouteTypes>() diff --git a/frontend/src/renderer/test/setup.ts b/frontend/src/renderer/test/setup.ts index 42215650e8..96f54a55d4 100644 --- a/frontend/src/renderer/test/setup.ts +++ b/frontend/src/renderer/test/setup.ts @@ -132,6 +132,13 @@ if (typeof window !== "undefined") { }), destroy: () => undefined, onNavState: () => () => undefined, + renderMarkdown: async () => ({ + documentId: "md://test-session/1", + url: "app://md-preview/md://test-session/1", + title: "Test Markdown", + revision: 1, + }), + onMarkdownFileChanged: () => () => undefined, }, notifications: { show: async () => undefined, diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index 6834782865..4d982ec7c5 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -130,6 +130,12 @@ export type WorkspaceSession = { updatedAt: string; /** Raw agent lifecycle activity from the daemon. */ activity?: SessionActivity; + /** + * Session worktree directory (e.g. ~/.ao/worktrees/<projectId>/<sessionId>). + * The markdown preview system uses this to resolve daemon-proxied file URLs + * to local paths for file watching and live refresh. + */ + workspacePath?: string; /** * Live preview target set by the daemon (via `ao preview`) and streamed over * CDC. When non-empty, the browser panel opens and navigates here. diff --git a/frontend/src/shared/markdown-types.ts b/frontend/src/shared/markdown-types.ts new file mode 100644 index 0000000000..56ecb9b6bd --- /dev/null +++ b/frontend/src/shared/markdown-types.ts @@ -0,0 +1,45 @@ +export type MarkdownSourceKind = "file" | "virtual" | "url"; + +export type MarkdownSource = + | { kind: "file"; path: string } + | { kind: "virtual"; content: string } + | { kind: "url"; url: string }; + +export interface MarkdownDocument { + id: string; + sessionId: string; + source: MarkdownSource; + renderedHtml: string; + title: string; + error?: string; + revision: number; + createdAt: number; + updatedAt: number; +} + +export interface RenderMarkdownRequest { + sessionId: string; + source: MarkdownSource; + workspacePath?: string; +} + +export interface RenderMarkdownResponse { + documentId: string; + url: string; + title: string; + revision: number; +} + +export interface MarkdownUpdateEvent { + documentId: string; + revision: number; + needsReload: boolean; + title?: string; +} + +export const MarkdownIpcChannels = { + fileChanged: "md:fileChanged", + stateChanged: "md:stateChanged", +} as const; + +export const MARKDOWN_FILE_RE = /\.md$/i; diff --git a/md_implementation.md b/md_implementation.md new file mode 100644 index 0000000000..234918eea0 --- /dev/null +++ b/md_implementation.md @@ -0,0 +1,585 @@ +# Markdown Preview in Browser Panel — Implementation + +PR [#2387](https://github.com/AgentWrapper/agent-orchestrator/pull/2387) adds the ability to render `.md` files as styled HTML inside the Electron browser panel, with file-watching for live refresh. This document explains the architecture, all changes, and known merge conflicts. + +--- + +## Motivation + +Before this PR, the browser panel could only navigate to HTTP/HTTPS/file URLs. Agents producing markdown output (reports, specs, summaries) had no way to preview it within the Electron UI. The implementation solves two problems: + +1. **Chromium caching:** The `app://md-preview` custom protocol response was being cached by Chromium — after a file change, `reload()` served stale HTML instead of re-invoking the protocol handler. +2. **Daemon proxy URLs:** When markdown files were served through the daemon proxy (`http://host/api/v1/sessions/<id>/preview/files/<entry>`), the `resolveLocalPath()` function only handled `file://` URLs, so chokidar watchers were never set up. + +--- + +## Architecture Overview + +```mermaid +flowchart TD + subgraph Renderer Process + BP[BrowserPanel.tsx] -->|MD URL detected| UB[useBrowserView.ts] + UB -->|renderMarkdown IPC| Preload + end + + subgraph Preload Bridge + Preload[preload.ts] -->|ipcRenderer.invoke| Main + end + + subgraph Main Process + Main[main.ts] -->|protocol.handle app://| MH[MarkdownHost] + MH -->|render via| MR[markdown-renderer.ts] + MR -->|marked + DOMPurify| HTML + MH -->|chokidar| FS[File System Watcher] + MH -->|md:fileChanged IPC| Preload + end + + subgraph Backend + API[HTTP API] -->|SessionView.workspacePath| Frontend + API -->|ao preview| FP[Proxy URL] + end + + FS -->|change event| MH + FP -->|daemon-proxy URL| MH + Main -->|Cache-Control: no-cache| Browser[Chromium BrowserView] + HTML -->|served via app://md-preview| Browser +``` + +--- + +## Data Flow + +```mermaid +sequenceDiagram + participant BP as BrowserPanel + participant UB as useBrowserView + participant P as Preload + participant MH as MarkdownHost + participant MR as markdown-renderer + participant FS as chokidar + participant BV as BrowserView + + BP->>UB: navigate/enter MD URL + UB->>P: renderMarkdown(sessionId, source) + P->>MH: IPC invoke + MH->>MR: renderMarkdown(content) + MR-->>MH: sanitized HTML + title + MH->>MH: cache document, setup watcher + MH-->>P: { documentId, url, title, revision } + P-->>UB: response + UB->>BV: navigate to app://md-preview/<id> + BV->>Main: protocol request + Main->>MH: getCachedHtml(id) + MH-->>Main: HTML with Cache-Control: no-cache + Main-->>BV: 200 OK + HTML + + Note over FS: File changes on disk + FS->>MH: change event + MH->>MR: re-render content + MH->>P: md:fileChanged IPC + P-->>UB: event + UB->>BV: reload() + BV->>Main: re-request app://md-preview/<id> +``` + +--- + +## Changes by Layer + +### 1. Backend Go — Surface `workspacePath` via API + +| File | Change | Motivation | +|---|---|---| +| `backend/internal/httpd/controllers/dto.go` | Added `WorkspacePath string` field to `SessionView` struct | Frontend needs the session worktree path to resolve daemon-proxied file URLs to local paths | +| `backend/internal/httpd/controllers/sessions.go` | Populated `WorkspacePath` from `s.Metadata.WorkspacePath` in `sessionView()` | Extract the curated field from the hidden metadata | +| `backend/internal/httpd/controllers/sessions_test.go` | Removed negative assertion `"list leaked workspacePath"` | `workspacePath` is now a curated field (not leaked metadata), so the assertion is obsolete | +| `backend/internal/httpd/apispec/openapi.yaml` | Added `workspacePath: string` to SessionView schema | Keep API spec in sync with the DTO | + +### 2. Backend Go — Multi-Skill System & Agent Prompting + +| File | Change | Motivation | +|---|---|---| +| `backend/internal/skillassets/skillassets.go` | Refactored from single-embed to multi-embed; renamed `SkillName` → `UsingAoName`; added `MarkdownPreviewName`; added `DirFor()` helper | Previously only the `using-ao` skill was embedded. Now any number of skills can coexist under `skills/`. | +| `backend/internal/skillassets/skillassets_test.go` | Updated test to verify both skills are installed; clobber test uses the common parent `skills/` | | +| `backend/internal/session_manager/manager.go` | Added `aoMarkdownPreviewPointer()` appended to every agent system prompt | Agents need to know they can produce `.md` output for the browser panel | + +**Embedded skill structure:** +``` +<dataDir>/skills/ +├── using-ao/ +│ ├── SKILL.md +│ └── commands/ +└── markdown-preview/ + └── SKILL.md +``` + +### 3. Frontend — New Shared Types + +**`frontend/src/shared/markdown-types.ts`** — Type definitions consumed by both main and renderer: + +| Type | Purpose | +|---|---| +| `MarkdownSourceKind` | `"file" \| "virtual" \| "url"` — discriminator for source variants | +| `MarkdownSource` | Union type: `file` (local path), `virtual` (inline content), `url` (remote/daemon-proxy URL) | +| `MarkdownDocument` | In-memory document with rendered HTML, revision counter, timestamps | +| `RenderMarkdownRequest` | IPC request payload: sessionId + source + optional workspacePath | +| `RenderMarkdownResponse` | IPC response: documentId + url + title + revision | +| `MarkdownUpdateEvent` | Event pushed to renderer when file-backed MD changes on disk | +| `MarkdownIpcChannels` | Channel name constants: `md:fileChanged`, `md:stateChanged` | +| `MARKDOWN_FILE_RE` | Regex `/\.md$/i` — used to detect markdown URLs | + +### 4. Frontend Main Process — Markdown Rendering Pipeline + +#### Why a custom protocol? (Why not just render MD → HTML in the BrowserView?) + +A `<BrowserView>` can only navigate to **URLs** — you cannot call `setInnerHTML` on it. The options for getting rendered HTML into it: + +| Approach | Problems | +|---|---| +| `data:text/html,...` | Fragile, limited size, no streaming, breaks back/forward, no cache control | +| `file:///tmp/foo.html` | Temp file cleanup, no dynamic cache busting, filesystem race conditions | +| `app://md-preview/<id>` | Clean, no temp files, full `Cache-Control` support, standard Electron idiom | + +Browsers render `.md` files as **unstyled plain text** (monospace, no formatting). To get styled output (headings, code blocks, syntax highlighting, dark mode), the parser (`marked` + `DOMPurify`) runs in the main process, and the result is served on-demand via a `protocol.handle("app", ...)` handler that routes `app://md-preview/*` to `MarkdownHost.getCachedHtml()`. This is the canonical Electron pattern for serving dynamic content to a webview — not overengineering. + +#### `markdown-renderer.ts` (new) + +The rendering pipeline: + +``` +Raw MD Text + │ + ▼ +marked.parse(source) ──→ raw HTML with tags + │ + ▼ +DOMPurify.sanitize(html) ──→ safe HTML (allowlisted tags/attrs only) + │ + ▼ +HTML_TEMPLATE.replace() ──→ full page with CSP + dark-mode styles + │ + ▼ +Final HTML string +``` + +Key design decisions: +- **`marked`** for markdown parsing (lightweight, fast, no native deps) +- **`DOMPurify`** running on a **`linkedom`** window (not `jsdom`) — minimal DOM implementation, avoids heavy jsdom dependency. `jsdom` was moved from `dependencies` to `peer`/`optional` in package.json. +- **Strict CSP**: `script-src 'none'` prevents any JS execution, even if DOMPurify has a bypass +- **`extractTitle()`** extracts first `<h1>` content for the page title +- Dark/light mode via `prefers-color-scheme` media query + +#### `markdown-host.ts` (new) + +Document lifecycle and file watching: + +| Method | Purpose | +|---|---| +| `render(request)` | Handle all three source kinds: file, virtual, url | +| `destroy(documentId)` | Remove a single document and its watchers | +| `destroySession(sessionId)` | Clean up all documents for a terminated session | +| `dispose()` | Teardown all watchers, documents, timers | +| `getCachedHtml(documentId)` | Serve cached HTML to the protocol handler | + +File watching flow: +``` +chokidar.watch(filePath) + │ + ├── awaitWriteFinish: { stabilityThreshold: 300ms } + │ + └── on "change" → + debounce 300ms → + readFile → renderMarkdown() → + IPC md:fileChanged to renderer → + renderer calls reload() +``` + +Daemon proxy URL resolution (`resolveLocalPath`): +- Parses `/api/v1/sessions/<id>/preview/files/<entry>` via regex +- Joins `<entry>` against `workspacePath` +- Guards against directory traversal: rejects if resolved path is outside `workspacePath` + +#### `main.ts` — Consolidated `app://` Protocol Handler + +Before: Separate `registerRendererProtocol()` for `app://renderer/*` only. + +After: Single `protocol.handle("app", ...)` that routes: +``` +app://renderer/* → registerRendererProtocolInner() (SPA) +app://md-preview/* → markdownHost.getCachedHtml(id) + Cache-Control headers +``` + +**Cache-Control fix** (commit `c74044e`): +``` +Cache-Control: no-cache, no-store, must-revalidate +Pragma: no-cache +Expires: 0 +``` +These headers prevent Chromium from caching the protocol response. When `reload()` is called after a file change, the protocol handler is re-invoked and returns fresh HTML. + +MarkdownHost lifecycle is wired to `app.whenReady()` and `app.on("before-quit")`. + +#### `browser-view-host.ts` + +Added `"app:"` to `ALLOWED_PROTOCOLS`: + +```diff +- const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:"]); ++ const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:", "app:"]); +``` + +The `app://renderer` origin is still blocked via the `isAllowedBrowserURL` check — only `app://md-preview` URLs are valid browser targets. + +### 5. Frontend Preload Bridge + +**`preload.ts`** — Two new API methods exposed to the renderer: + +| Method | IPC Channel | Direction | +|---|---|---| +| `browser.renderMarkdown(sessionId, source, workspacePath?)` | `browser:renderMarkdown` (invoke/handle) | Renderer → Main | +| `browser.onMarkdownFileChanged(listener)` | `md:fileChanged` (on/off) | Main → Renderer | + +### 6. Frontend Renderer + +#### `hooks/useBrowserView.ts` + +Key additions: +- **`renderMarkdown(source)`** — calls preload bridge, navigates view to `app://md-preview/<id>`, stores `currentDocIdRef` +- **`onMarkdownFileChanged` effect** — listens for file change events; if the event's `documentId` matches `currentDocIdRef`, calls `reload()` on the browser view +- **`previewUrl` effect updated** — when the daemon pushes a new preview URL that matches `MARKDOWN_FILE_RE`, it routes through `renderMarkdown()` instead of `navigate()` + +#### `components/BrowserPanel.tsx` + +- Receives `workspacePath` from session +- On form submit, normalises bare absolute paths (`/foo/bar.md` → `file:///foo/bar.md`) so MarkdownHost detects them as local files +- If the URL matches `MARKDOWN_FILE_RE`, calls `browserView.renderMarkdown()` instead of `navigate()` + +#### `components/SessionView.tsx` + +- Passes `workspacePath` to `useBrowserView()` + +#### `hooks/useWorkspaceQuery.ts` + +- Added `workspacePath: session.workspacePath` in the session mapper + +#### `types/workspace.ts` + +- Added `workspacePath?: string` to `WorkspaceSession` + +#### Test stubs + +`bridge.ts`, `test/setup.ts`, `useBrowserView.test.tsx` — all updated with stub `renderMarkdown` and `onMarkdownFileChanged` to prevent test failures. + +### 7. Dependencies + +| Package | Version | Purpose | +|---|---|---| +| `marked` | ^15.0.11 | Markdown → HTML parser | +| `dompurify` | ^3.2.4 | HTML sanitisation | +| `linkedom` | ^0.18.12 | Lightweight DOM for DOMPurify (replaces jsdom) | +| `chokidar` | ^5.0.0 | OS-native file watching | +| ~~`jsdom`~~ | removed | No longer needed (replaced by linkedom) | + +--- + +## Merge Conflict with `origin/main` + +There is **one conflict** in `frontend/src/renderer/hooks/useWorkspaceQuery.ts`: + +``` +<<<<<<< .our (origin/main) + activity: toSessionActivity(session.activity), +======= + workspacePath: session.workspacePath, +>>>>>>> .their (fix/markdown-preview-cache) +``` + +### Root Cause + +Both branches add a field to the same session mapper object at the same position (between `updatedAt` and `previewUrl`): + +| Branch | Field Added | Feature | +|---|---|---| +| `origin/main` (PR #2325) | `activity: toSessionActivity(session.activity)` | Tracker intake — GitHub issue-to-session lifecycle | +| `fix/markdown-preview-cache` (PR #2387) | `workspacePath: session.workspacePath` | Markdown preview — daemon proxy URL file watching | + +### Resolution + +Keep **both** fields — they are independent and non-overlapping: + +```typescript +updatedAt: session.updatedAt, +activity: toSessionActivity(session.activity), +workspacePath: session.workspacePath, +previewUrl: session.previewUrl, +``` + +The `workspace.ts` type file has no conflict — `origin/main` adds `activity` and `issueId` fields, `fix/markdown-preview-cache` adds `workspacePath` — they are at different positions in the type. + +No other files have merge conflicts — the remaining 24 files were changed only on the `fix/markdown-preview-cache` branch. + +--- + +## Post-PR Improvement: Arbitrary File Path Support + +After PR #2387, users can only open markdown files that are either `file://` URLs, `/`-prefixed absolute paths, or daemon-proxy URLs. Paths in Windows UNC format (`\\wsl.localhost\…`) or with drive letters (`C:\…`) fail because: + +1. `BrowserPanel.tsx` only converted `/`-prefixed paths to `file://` +2. `markdown-host.ts` `resolveLocalPath()` had no fallback for unrecognised path formats + +### Changes + +#### `BrowserPanel.tsx` — Broader path normalisation + +The single `startsWith("/")` check was replaced with three branches: + +| Input Pattern | Example | Conversion | Notes | +|---|---|---|---| +| `\\` or `//` prefix | `\\wsl.localhost\Ubuntu\file.md` | `file:////wsl.localhost/Ubuntu/file.md` | UNC path — uses 4 slashes (`file:////`) which `fileURLToPath` decodes back to `\\host\share\…` on Windows | +| `/` or `\` prefix | `/home/user/file.md` | `file:///home/user/file.md` | Unix absolute or backslash-prefixed paths | +| `[A-Za-z]:` prefix | `C:\Users\user\file.md` | `file:///C:/Users/user/file.md` | Windows drive letter — uses 3 slashes (`file:///`) which is the standard `file:///C:/…` form | + +Backslashes are converted to forward slashes in all cases. + +#### `markdown-host.ts` — Filesystem fallback in URL handler + +After `resolveLocalPath()` returns null, a new helper `tryLocalFile()` attempts direct filesystem access: + +``` +source.kind === "url" + │ + ├─ resolveLocalPath(url, workspacePath) ── success → readFile + watcher + │ + ├─ tryLocalFile(url) ── success → readFile + watcher + │ │ + │ └─ Skips if URL matches ^https?:// (defers to fetch) + │ └─ Strips file:// prefix + normalizes backslashes + │ └─ existsSync check + │ + └─ fetch(url) ── HTTP fallback +``` + +This catches edge cases where `fileURLToPath` produces a path the platform rejects (e.g., UNC path on Linux), or where the `BrowserPanel` normaliser didn't convert the input. + +### Path Resolution Table + +| User Input | `BrowserPanel.tsx` output | `resolveLocalPath` result | `tryLocalFile` result | Works? | +|---|---|---|---|---| +| `/home/user/file.md` | `file:///home/user/file.md` | `fileURLToPath` → `/home/user/file.md` → exists | — | ✅ | +| `\\wsl.localhost\…\file.md` | `file:////wsl.localhost/…/file.md` | `fileURLToPath` → `\\wsl.localhost\…\file.md` → depends on OS | skips (already matched `file://`) | ✅ on Windows, ❌ on Linux | +| `C:\Users\…\file.md` | `file:///C:/Users/…/file.md` | `fileURLToPath` → `C:\Users\…\file.md` → depends on OS | skips (already matched `file://`) | ✅ on Windows, ❌ on Linux | +| `/api/v1/sessions/…/preview/files/entry.md` | unchanged | `parseDaemonProxyEntry` → resolved via workspacePath | — | ✅ | +| `https://example.com/doc.md` | unchanged | null (no match) | skipped (HTTP scheme) | ✅ via fetch | +| `raw/backslash/path.md` (no prefix, no scheme) | unchanged | null | `existsSync` check → read + watch | ✅ if file exists | + +## Summary + +```mermaid +flowchart LR + subgraph "Problem 1: Chromium Cache" + A1[browser reload()] -->|stale cached HTML| A2[❌ No re-render] + A1 -->|Cache-Control headers| A3[✅ Fresh HTML] + end + + subgraph "Problem 2: Daemon Proxy URLs" + B1[proxy URL like\n/api/v1/sessions/...] --> B2[resolveLocalPath\nonly handled file://] + B2 --> B3[❌ No watcher setup] + B1 --> B4[parseDaemonProxyEntry\n+ workspacePath] + B4 --> B5[✅ Watcher on local file] + end + + subgraph "Feature: MD Preview" + C1[.md file detected] --> C2[renderMarkdown IPC] + C2 --> C3[marked + DOMPurify] + C3 --> C4[app://md-preview/<id>] + C4 --> C5[chokidar watches file] + C5 -->|change| C6[auto-reload] + end +``` + +--- + +## Post-PR Fix: CLI Relative Path Resolution + +When the user runs `ao preview README.md` from the worktree directory, the raw relative path `"README.md"` was sent to the daemon verbatim. The daemon's `resolvePreviewTarget` tried to resolve it against the session's `WorkspacePath` via `resolveLocalPreview` → `confinedPreviewPath`, but when the user's CWD didn't match the session's stored workspace path (or for any other reason the file wasn't found at `workspacePath/README.md`), the function returned false and the daemon persisted the bare string `"README.md"` as the `preview_url`. + +On the Electron side, `MarkdownHost.resolveLocalPath("README.md")` failed to match any known pattern: +- Not a `file://` URL (no `file://` prefix) +- Not a daemon-proxy URL (no `/api/v1/sessions/.../preview/files/...` pattern) +- Not an HTTP URL (no scheme) + +The fallback `tryLocalFile("README.md")` also failed because the Electron main process's CWD is not the worktree directory. The browser panel stored the URL but never rendered anything — a silent failure. + +### Changes + +| File | Change | Motivation | +|---|---|---| +| `backend/internal/cli/preview.go` | Added `path/filepath` import; resolved bare relative paths to absolute in `openPreview()` | The daemon already handles absolute paths via `absolutePreviewFileURL` (stats the file, converts to `file://` URL), and Electron's `resolveLocalPath` handles `file://` URLs correctly. | +| `backend/internal/cli/preview.go` | Updated `Long`/`Example` to show relative path support | The existing help text only showed `file://$(pwd)/...` as a workaround; relative paths now work directly. | +| `backend/internal/cli/preview_test.go` | Added `TestPreview_RelativePathResolvedToAbsolute` + `TestPreview_RelativePathNonExistentPassedThrough` | Verify that existing files are resolved to absolute paths and non-existent files pass through unchanged. | + +### Resolution flow + +``` +Terminal: ao preview README.md + │ (CWD = ~/.ao/data/worktrees/dummy/dummy-56/) + │ + ├── CLI: openPreview("README.md") + │ ├── filepath.IsAbs("README.md") → false + │ ├── filepath.Abs("README.md") → "/home/.../dummy-56/README.md" + │ ├── os.Stat("/home/.../dummy-56/README.md") → exists + │ └── target = "/home/.../dummy-56/README.md" (absolute path) + │ + ├── POST {"url":"/home/.../dummy-56/README.md"} → daemon + │ + ├── Daemon: resolvePreviewTarget(...) + │ ├── isAbsolutePreviewPath("/home/.../README.md") → true + │ ├── absolutePreviewFileURL("/home/.../README.md") + │ │ ├── os.Stat → exists + │ │ └── returns "file:///home/.../dummy-56/README.md" + │ └── persistence: SQLite stores file:// URL + │ + ├── CDC trigger → SSE → Electron + │ + └── Electron: BrowserPanel detects .md → renderMarkdown() + └── MarkdownHost.resolveLocalPath("file:///home/.../README.md") + ├── file:// prefix detected + ├── fileURLToPath → "/home/.../dummy-56/README.md" + ├── fs.exists → found + ├── read → marked + DOMPurify → HTML + ├── chokidar watcher on path + └── navigate to app://md-preview/<id> ✅ +``` + +### Design decisions + +- **Stat before resolving**: The `os.Stat` guard prevents converting `localhost:5173` or other non-file arguments into absolute paths. Only if the relative path points at an existing file on disk is the conversion performed. +- **Pass-through for non-existent files**: When the relative path doesn't exist, the CLI sends the original string verbatim and the daemon's existing fallback path applies (it will either find it via `resolveLocalPreview` against `WorkspacePath`, or persist the raw string and let Electron fail). +- **No Electron-side change**: The fix is entirely in the CLI. The daemon's `absolutePreviewFileURL` already converts absolute paths to `file://` URLs, and Electron's `resolveLocalPath` already handles `file://` URLs correctly — the missing link was that relative paths never became absolute before hitting the wire. + +### Path Resolution Table (updated) + +| User Input | CLI preprocessing | Daemon receives | Electron resolves | Works? | +|---|---|---|---|---| +| `README.md` | → `/abs/path/README.md` (stat) | `/abs/path/README.md` → `file:///abs/path/README.md` | `fileURLToPath` → read + watch | ✅ | +| `./dist/index.html` | → `/abs/path/dist/index.html` (stat) | `/abs/path/dist/index.html` → `file:///abs/path/dist/index.html` | `fileURLToPath` → read + watch | ✅ | +| `nonexistent.md` | unchanged (stat fails) | `"nonexistent.md"` → falls through daemon's `resolveLocalPreview` vs `WorkspacePath` | depends on workspace match | ⚠️ (unchanged behavior) | +| `localhost:5173` | unchanged (stat fails) | verbatim (daemon treats as host:port) | `tryLocalFile` fails → `fetch` | ✅ via HTTP | +| `/absolute/path.md` | unchanged (already absolute) | daemon's `absolutePreviewFileURL` → `file://` | `file://` → read + watch | ✅ | +| `http://example.com/doc.md` | unchanged (no `filepath.IsAbs` hit) | verbatim | `fetch` via HTTP | ✅ | + +--- + +## Post-PR Feature: CSS-Only Dark/Light Theme Toggle + +After PR #2387, rendered markdown pages used a `@media (prefers-color-scheme: dark)` query to auto-detect the system theme. There was no manual toggle — users whose system was in light mode could not switch the preview to dark mode, and vice versa. + +The fix adds a clickable sun/moon toggle button in the top-right corner of every rendered markdown page, using a **CSS-only checkbox hack** that requires zero JavaScript and fully respects the `script-src 'none'` CSP. + +### How it works + +A hidden `<input type="checkbox" id="theme-toggle">` sits before the content `<div>` inside `<body>`. A `<label for="theme-toggle">` styled as a 34px frosted-glass circle is fixed to the top-right corner. Clicking the label toggles the checkbox, and CSS adjacent-sibling selectors swap all color properties: + +``` +Checkbox unchecked (default) Checkbox checked + │ │ + ▼ ▼ +#theme-toggle:not(:checked) #theme-toggle:checked + ~ .content { color, borders } ~ .content { color, borders } + body:has(...) { background } body:has(...) { background } + │ │ + ▼ ▼ + Light theme ☀ Dark theme ☾ +``` + +The label's `::after` pseudo-element shows `☾` when in light mode (click to go dark) and `☀` when in dark mode (click to go light), using the CSS-only content swap: + +```css +.theme-toggle-label::after { content: "\263E"; } +#theme-toggle:checked + .theme-toggle-label::after { content: "\2600"; } +``` + +### Full-viewport background fix + +The initial implementation had the background color on the `.content` div, which has `max-width: 920px; margin: 0 auto`. When toggling to dark mode, the body's default white background was visible on either side of the centered content column — a white border. + +Fixed by moving the background to `<body>` via the `:has()` selector (Chromium 130+, Electron 33): + +```css +body:has(#theme-toggle:not(:checked)) { background: #ffffff; } +body:has(#theme-toggle:checked) { background: #1a1a1a; } +body { transition: background 0.25s; } +``` + +This paints the background behind the full viewport regardless of the `.content` div's centering. + +### Changes + +| File | Change | Motivation | +|---|---|---| +| `frontend/src/main/markdown-renderer.ts` | Replaced `@media (prefers-color-scheme: dark)` theme with CSS-only checkbox toggle | Give users manual light/dark control without violating `script-src 'none'` CSP | +| `frontend/src/main/markdown-renderer.ts` | Moved background from `.content` to `body:has()` | Fix white border visible outside `.content`'s `max-width` bounds | + +No other files changed — no new IPC channels, no new types, no changes to the host, renderer, or browser view. The toggle is entirely self-contained within each rendered HTML page. + +### CSP compatibility + +The toggle relies on three features that the strict CSP already allows: + +| Feature | CSP directive | Status | +|---|---|---| +| `<label for="...">` | HTML, no CSP restriction | ✅ Always allowed | +| `:checked`, `:not(:checked)` pseudo-classes | CSS, no CSP restriction | ✅ `style-src 'unsafe-inline'` | +| `:has()` parent selector | CSS, no CSP restriction | ✅ `style-src 'unsafe-inline'` | + +No changes to the CSP policy were needed. + +--- + +## Post-PR Feature: File Deletion Handling & Agent Auto-Preview Instructions + +After the initial markdown preview implementation, two gaps were identified in the user workflow: + +1. **File deletion left stale content**: When a watched `.md` file was deleted from disk, the chokidar watcher fired an `unlink` event that was not handled. The browser panel continued displaying the old rendered HTML with no indication the file was gone. + +2. **Agent workflow required manual `ao preview`**: The SKILL.md instructed agents to "tell the user the path", requiring the user to manually open the file. Agents could not proactively push the preview to the panel. + +### File deletion handling + +**`frontend/src/main/markdown-host.ts`** — The chokidar watcher already handled `change` and `error` events, but `unlink` (file deletion) was unhandled. A new `onFileDeleted()` method was added: + +``` +chokidar.watch(filePath) + │ + ├── "change" → onFileChanged() → re-read + re-render + IPC + ├── "unlink" → onFileDeleted() → render deletion notice + IPC + close watcher + └── "error" → console.error +``` + +The `onFileDeleted` method: +- Removes all documents referencing the deleted path from the cache (so `getCachedHtml` returns `null`) +- Closes the chokidar watcher for that path +- Makes an HTTP `DELETE /api/v1/sessions/{id}/preview` call to the daemon + +The daemon's `clearPreview` handler (DELETE endpoint) was updated to autodetect `index.html` in the workspace before clearing. If `index.html` exists, the preview reverts to it. If not, the preview URL is cleared and the browser panel shows its blank initial state. No error page or deletion notice is shown. + +### Agent auto-preview instructions + +**`backend/internal/skillassets/markdown-preview/SKILL.md`** — The "Using from a session" section was expanded with new steps: + +| Step | Instruction | Rationale | +|---|---|---| +| 2 | After creating a `.md` file, run `ao preview <path>` immediately | The user sees rendered output without manual steps | +| 3 | If producing multiple `.md` files, only auto-preview the **last** one | The panel can show only one at a time; previous targets are replaced instantly | + +The agent's system prompt already had the `aoMarkdownPreviewPointer()` appended (from the earlier multi-skill work), which tells agents to read this SKILL.md file. No changes to `session_manager/manager.go` were needed. + +### Changes + +| File | Change | Motivation | +|---|---|---| +| `frontend/src/main/markdown-host.ts` | Added `unlink` handler + `onFileDeleted()` method that deletes the cached doc and calls daemon to revert | Browser panel reverts to `index.html` or blank — no stale content, no error page | +| `frontend/src/main/markdown-host.ts` | Added `setDaemonPort()` method | MarkdownHost needs the daemon port to make DELETE calls | +| `frontend/src/main.ts` | Calls `markdownHost.setDaemonPort(port)` from `reportBoundPort` | Wires the daemon port into MarkdownHost when it's confirmed | +| `backend/internal/httpd/controllers/sessions.go` | Updated `clearPreview` (DELETE) to autodetect `index.html` before clearing | When a file-backed preview is deleted, the daemon reverts to the default entry point instead of showing stale content | +| `backend/internal/skillassets/markdown-preview/SKILL.md` | Expanded with auto-preview steps; documented file deletion behavior | Agents can proactively push previews without manual user action |