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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions content/guides/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ URL **fragment** carries a random session token. Open that URL in your browser;
every API request must repeat the token, and any supplied `Origin` must match
the session origin. A bare `--boris` command name is resolved through `PATH`;
a path-like value is canonicalized against the directory you launched from.
Without an `open=` fragment the editor restores the last author-owned file
you had open, or `content/index.md` when that file is present. An `open=`
fragment still wins, including when it is ignored as unsafe.

`Ctrl+K` (or `Cmd+K`) opens a command palette for file actions, Boris
commands, preview rebuild, and jumping to a project file. Esc, the palette's
Expand Down Expand Up @@ -85,7 +88,9 @@ then atomically renames it into place.
change does not wait for Save. Transient filesystem errors are skipped and
retried.
- **Undo / redo** — session-local, keyboard-driven (`Ctrl/Cmd+Z`, `Ctrl/Cmd+
Shift+Z`) and via the toolbar.
Shift+Z`) and via the toolbar. Consecutive inserts coalesce into one undo
step until a pause, a word boundary, or a non-insert (delete, paste,
undo/redo).
- **Recovery snapshots** — dirty buffers are snapshotted to the disposable OS
user-cache state root on the first unsaved change, then periodically, and
again when the tab hides. A later editor process labels them as *recovered
Expand Down Expand Up @@ -265,7 +270,9 @@ boris build --input content --incremental --html-dir dist
```

You can also trigger it with the **Rebuild preview** button. The UI reports
`idle`, `running`, `success`, `failed`, and `stale` distinctly. Boris's staged
`idle`, `running`, `success`, `failed`, and `stale` distinctly. If
`dist/index.html` already exists when the editor starts, preview is `stale`
with that content in the iframe rather than an empty idle pane. Boris's staged
output commit preserves the last valid `dist/` tree after a failed rebuild, so
the preview iframe advances only on success. Embedded preview content is
sandboxed; a named link opens the exact site origin in a new tab for full
Expand Down
15 changes: 15 additions & 0 deletions docs/changelog.d/982-editor-graph-fit-undo-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!--
Filename: 982-editor-graph-fit-undo-preview.md
Keep exactly one category heading. Replace this example link with a relevant
repository-root-relative link; contract-visible work links its updated contract.
-->

### Fixed

- Editor graph map opens at a readable zoom (50% floor) centered on the active
page while Fit still shows the whole graph; typing coalesces into one undo
step per phrase; a cold launch restores the last author-owned file from the
disposable state root (or `content/index.md`); and an existing `dist/` tree
is framed as stale instead of an empty idle preview. Links: [the editor-host
contract](/docs/contracts/editor-host.md), [the editor
guide](/content/guides/editor.md).
23 changes: 19 additions & 4 deletions docs/contracts/editor-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ calls carry it in the `x-boris-editor-token` header instead. An embedder may
append `&open=<project-relative path>` to the fragment as a shell-side
convenience; the host never reads a file from the URL and its transport posture
is unchanged (see [`editor/README.md`](../../editor/README.md#launch-line-contract)).
Without `open=`, the shell may restore `last_open` from `GET /api/files` or
open `content/index.md` when present (§5.3).

### 3.2 Loopback and header discipline

Expand Down Expand Up @@ -307,12 +309,21 @@ A malformed fingerprint is `400 invalid_fingerprint`.

### 5.3 Responses and shapes

`/api/files` returns a **flat** list, sorted lexicographically by full path:
`/api/files` returns a **flat** list, sorted lexicographically by full path,
plus the last author-owned path successfully opened, created, or renamed in
this project (stored under the disposable state root, never project truth):

```json
{"files":[{"path":"boris.json"},{"path":"content/index.md"}]}
{"files":[{"path":"boris.json"},{"path":"content/index.md"}],
"last_open":"content/index.md"}
```

`last_open` is `null` when none has been recorded, the recorded path failed
`validatePath`, or the state-root file is missing or unreadable. A successful
`open` / `create` / `rename` records the new path; a successful `delete` of
that path clears it. The shell may restore it on a cold launch that has no
`open=` fragment, and must still ignore unsafe values.

`open`, `save` (both outcomes), and `create` share the buffer shape:

```json
Expand Down Expand Up @@ -677,11 +688,15 @@ used_stderr_fallback, message, preview_url, watch_active}`:

| `phase` | Meaning |
|---|---|
| `idle` | No preview output has been built in this session |
| `idle` | No `dist/index.html` exists yet — nothing to frame |
| `running` | A rebuild is in flight |
| `success` | The rebuild succeeded; `generation` advanced |
| `failed` | The rebuild failed and no valid output exists |
| `stale` | Output exists, but from an earlier build or a failed rebuild |
| `stale` | Output exists from an earlier build, a failed rebuild, or a `dist/` tree that was already on disk when the editor started |

If `dist/index.html` is present when the host process starts, the first
`/api/preview/state` is `stale` (generation `0`) rather than empty `idle`, so
the shell can frame the existing bytes.

`generation` advances **only** on success, so the shell can reload the iframe
exactly once per successful build. On failure `message` is the last
Expand Down
10 changes: 8 additions & 2 deletions editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ API ([`file_api.validatePath`](src/file_api.zig)): exactly `boris.json` or a
regular file below `content/` or `themes/`, with no leading `/`, no `..`
segments, and no backslashes. An invalid path is ignored with a status message;
a well-formed but missing path surfaces the host's `file_not_found`; either way
the editor still boots to the project file list. The fragment is consumed only
the editor still boots to the project file list. **`open=` wins:** a present
fragment, even an ignored unsafe one, is never overridden by a restored path.
Without `open=`, the shell restores `last_open` from `GET /api/files` when that
path is still author-owned and still in the list, otherwise it opens
`content/index.md` when that file is present. Unsafe recorded paths are ignored
the same way as an unsafe fragment. The fragment is consumed only
by the shell — the host keeps printing the token-only launch line and never
reads a file from the URL, so the token/CSP/loopback posture is unchanged.

Expand Down Expand Up @@ -206,7 +211,8 @@ truth unless the author explicitly saves it.

The authenticated file API is intentionally small:

- `GET /api/files` and `POST /api/files/open` enumerate and open safe files;
- `GET /api/files` and `POST /api/files/open` enumerate and open safe files
(`last_open` on the list is the disposable last author-owned path);
- `POST /api/files/probe` compares the open-file fingerprint to disk without
writing; transient filesystem errors stay in-session;
- `POST /api/files/save`, `/create`, `/rename`, and `/delete` perform explicit
Expand Down
7 changes: 7 additions & 0 deletions editor/scripts/test-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ api_get /api/version | grep -q '"compiler_id":"boris/'

files="$(api_get /api/files)"
printf '%s' "$files" | grep -q '"path":"content/index.md"'
printf '%s' "$files" | grep -q '"last_open":null'
if printf '%s' "$files" | grep -Eq 'dist/index.html|\.boris/graph.json'; then
echo "generated output escaped into the author file list" >&2
exit 1
Expand All @@ -110,6 +111,12 @@ opened="$(api_post /api/files/open '{"path":"content/index.md"}')"
[[ "$(printf '%s' "$opened" | code_of)" == "200" ]]
fingerprint="$(printf '%s' "$opened" | body_of | fingerprint_of)"
[[ ${#fingerprint} -eq 64 ]]
files="$(api_get /api/files)"
printf '%s' "$files" | grep -q '"last_open":"content/index.md"'
stop_editor
start_editor
files="$(api_get /api/files)"
printf '%s' "$files" | grep -q '"last_open":"content/index.md"'

# An external write after open must produce a 409 and preserve both versions.
printf '# External edit\n' >"$work/project/content/index.md"
Expand Down
90 changes: 90 additions & 0 deletions editor/src/last_open.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Last-opened author path, stored under the disposable editor state root.
//! Never project truth: a missing, corrupt, or unsafe file is ignored.

const std = @import("std");
const Io = std.Io;
const file_api = @import("file_api.zig");

const file_name = "last-open.json";

const Document = struct {
path: []const u8,
};

pub fn save(allocator: std.mem.Allocator, io: Io, state_root: []const u8, path: []const u8) !void {
try file_api.validatePath(path);
try Io.Dir.cwd().createDirPath(io, state_root);
var dir = try Io.Dir.cwd().openDir(io, state_root, .{ .follow_symlinks = false });
defer dir.close(io);

const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .path = path }, .{});
defer allocator.free(bytes);
var atomic = try dir.createFileAtomic(io, file_name, .{ .replace = true });
defer atomic.deinit(io);
var write_buffer: [1024]u8 = undefined;
var writer = atomic.file.writer(io, &write_buffer);
try writer.interface.writeAll(bytes);
try writer.flush();
try atomic.file.sync(io);
try atomic.replace(io);
}

pub fn load(allocator: std.mem.Allocator, io: Io, state_root: []const u8) std.mem.Allocator.Error!?[]u8 {
var dir = Io.Dir.cwd().openDir(io, state_root, .{ .follow_symlinks = false }) catch return null;
defer dir.close(io);
const bytes = dir.readFileAlloc(io, file_name, allocator, .limited(8192)) catch return null;
defer allocator.free(bytes);
var parsed = std.json.parseFromSlice(Document, allocator, bytes, .{}) catch return null;
defer parsed.deinit();
file_api.validatePath(parsed.value.path) catch return null;
return try allocator.dupe(u8, parsed.value.path);
}

pub fn clear(io: Io, state_root: []const u8) !void {
var dir = Io.Dir.cwd().openDir(io, state_root, .{ .follow_symlinks = false }) catch |err| switch (err) {
error.FileNotFound => return,
else => |other| return other,
};
defer dir.close(io);
dir.deleteFile(io, file_name) catch |err| switch (err) {
error.FileNotFound => {},
else => |other| return other,
};
}

test "last-open path survives a new store instance" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var temp = std.testing.tmpDir(.{});
defer temp.cleanup();
const root = try temp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(root);

try std.testing.expect(try load(allocator, io, root) == null);
try save(allocator, io, root, "content/guides/start.md");
const loaded = try load(allocator, io, root);
defer if (loaded) |path| allocator.free(path);
try std.testing.expectEqualStrings("content/guides/start.md", loaded.?);
try clear(io, root);
try std.testing.expect(try load(allocator, io, root) == null);
}

test "unsafe last-open files are ignored" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var temp = std.testing.tmpDir(.{});
defer temp.cleanup();
const root = try temp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(root);

try std.testing.expectError(error.PathNotAuthorOwned, save(allocator, io, root, "dist/index.html"));
try std.testing.expectError(error.InvalidPath, save(allocator, io, root, "../secret"));

try Io.Dir.cwd().createDirPath(io, root);
var dir = try Io.Dir.cwd().openDir(io, root, .{ .follow_symlinks = false });
defer dir.close(io);
try dir.writeFile(io, .{ .sub_path = file_name, .data = "{\"path\":\"dist/index.html\"}" });
try std.testing.expect(try load(allocator, io, root) == null);
try dir.writeFile(io, .{ .sub_path = file_name, .data = "{not-json" });
try std.testing.expect(try load(allocator, io, root) == null);
}
2 changes: 2 additions & 0 deletions editor/src/main.zig
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const std = @import("std");
const authoring = @import("authoring.zig");
const graph = @import("graph.zig");
const last_open = @import("last_open.zig");
const contracts = @import("contracts.zig");
const diagnostic_packet = @import("diagnostic_packet.zig");
const file_api = @import("file_api.zig");
Expand Down Expand Up @@ -197,6 +198,7 @@ test "boris path resolution canonicalizes paths and passes through command names
test {
_ = authoring;
_ = graph;
_ = last_open;
_ = contracts;
_ = diagnostic_packet;
_ = file_api;
Expand Down
25 changes: 24 additions & 1 deletion editor/src/server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const project = @import("project.zig");
const preview = @import("preview.zig");
const publication = @import("publication.zig");
const recovery = @import("recovery.zig");
const last_open = @import("last_open.zig");
const runner = @import("runner.zig");
const security = @import("security.zig");
const validation_daemon = @import("validation_daemon.zig");
Expand Down Expand Up @@ -291,7 +292,9 @@ const SnapshotRequest = struct {
fn serveFileList(io: Io, allocator: std.mem.Allocator, request: *http.Server.Request, config: Config) !void {
var files = file_api.list(allocator, io, config.project_root) catch |err| return respondApiError(request, err);
defer files.deinit(allocator);
const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .files = files.entries }, .{});
const last_path = try last_open.load(allocator, io, config.state_root);
defer if (last_path) |path| allocator.free(path);
const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .files = files.entries, .last_open = last_path }, .{});
defer allocator.free(bytes);
return respondJson(request, .ok, bytes);
}
Expand Down Expand Up @@ -326,6 +329,7 @@ fn serveFileOpen(io: Io, allocator: std.mem.Allocator, request: *http.Server.Req
defer parsed.deinit();
var buffer = file_api.open(allocator, io, config.project_root, parsed.value.path) catch |err| return respondApiError(request, err);
defer buffer.deinit(allocator);
rememberLastOpen(allocator, io, config, parsed.value.path);
return respondBuffer(allocator, request, .ok, "opened", parsed.value.path, buffer);
}

Expand Down Expand Up @@ -367,6 +371,7 @@ fn serveFileCreate(io: Io, allocator: std.mem.Allocator, request: *http.Server.R
var buffer = file_api.create(allocator, io, config.project_root, parsed.value.path, parsed.value.content) catch |err| return respondApiError(request, err);
defer buffer.deinit(allocator);
config.daemon.noteSave();
rememberLastOpen(allocator, io, config, parsed.value.path);
return respondBuffer(allocator, request, .created, "created", parsed.value.path, buffer);
}

Expand All @@ -379,6 +384,7 @@ fn serveFileRename(io: Io, allocator: std.mem.Allocator, request: *http.Server.R
recovery.clear(io, config.state_root, parsed.value.path) catch |err| {
std.log.warn("could not clear recovery snapshot after rename: {s}", .{@errorName(err)});
};
rememberLastOpen(allocator, io, config, parsed.value.new_path);
config.daemon.noteSave();
const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .status = "renamed", .path = parsed.value.new_path }, .{});
defer allocator.free(bytes);
Expand All @@ -394,10 +400,27 @@ fn serveFileDelete(io: Io, allocator: std.mem.Allocator, request: *http.Server.R
recovery.clear(io, config.state_root, parsed.value.path) catch |err| {
std.log.warn("could not clear recovery snapshot after delete: {s}", .{@errorName(err)});
};
forgetLastOpenIfMatch(allocator, io, config, parsed.value.path);
config.daemon.noteSave();
return respondJson(request, .ok, "{\"status\":\"deleted\"}");
}

fn rememberLastOpen(allocator: std.mem.Allocator, io: Io, config: Config, path: []const u8) void {
last_open.save(allocator, io, config.state_root, path) catch |err| {
std.log.warn("could not record last-open path: {s}", .{@errorName(err)});
};
}

fn forgetLastOpenIfMatch(allocator: std.mem.Allocator, io: Io, config: Config, path: []const u8) void {
const current = last_open.load(allocator, io, config.state_root) catch return;
const owned = current orelse return;
defer allocator.free(owned);
if (!std.mem.eql(u8, owned, path)) return;
last_open.clear(io, config.state_root) catch |err| {
std.log.warn("could not clear last-open path: {s}", .{@errorName(err)});
};
}

fn serveRecoveryList(io: Io, allocator: std.mem.Allocator, request: *http.Server.Request, config: Config) !void {
var snapshots = recovery.loadAll(allocator, io, config.state_root) catch |err| return respondApiError(request, err);
defer snapshots.deinit(allocator);
Expand Down
10 changes: 9 additions & 1 deletion editor/ui/src/App.svelte

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View All Evidence

Medium severity Failed rebuild hides the last valid preview

What failed: The failed rebuild removed the visible preview iframe and left the status at running instead of showing a failed or stale state with the generation-zero frame. The recorded UI message was 'Preview host failed: request failed. Existing output is not current.'

Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
  • Severity: Medium Medium severity
  • Impact: When a preview rebuild fails, editors lose the last working preview and cannot inspect it until another rebuild succeeds. This interrupts the preview workflow but does not alter the saved project or its data.
  • Steps to Reproduce:
    1. Open the editor with an existing stale generation-zero preview so the preview frame is visible.
    2. Click Rebuild preview and make the rebuild request fail.
    3. Check the Preview pane after the failure.
    4. Run a successful rebuild and check that generation 1 renders, confirming that the earlier missing frame was not a permanent fixture limitation.
  • Stub / mock content: The test used local in-page API mocks for the editor endpoints and a tokened preview URL. The preview mock supplied stale generation 0, a failed rebuild response, and then a successful generation-1 response; no production services, credentials, or application files were used.
  • Code Analysis: The direct defect is in editor/ui/src/App.svelte:250-270. rebuildPreview stores previousData and optimistically sets preview.data.phase to running at lines 255-256. If the request succeeds, setPreview replaces that state at lines 260-262. If the request is a watch-daemon refusal, the function restores previousData at lines 263-268, but the general error branch at line 269 only changes preview.status and never restores preview.data or applies the failed/stale response returned by the host. Consequently preview.data remains phase=running. The Preview pane then follows editor/ui/src/components/PreviewPane.svelte:28-31 and 61-70: it hides the new-tab link and iframe unless phase is success or stale, and shows 'No valid Boris preview output is available yet' for running. This matches the observed missing iframe. The host-side implementation in editor/src/preview.zig:62-78 preserves the last dist tree and reports stale when an index exists after a failed build, so the UI is discarding the valid state rather than the build output being unavailable. The smallest fix is to handle every non-watch rebuild error by restoring previousData when it exists, or by converting the returned failed/stale PreviewState into preview.data, while setting the failure message; do not leave the optimistic running state behind. This keeps the generation unchanged on failure and lets the existing PreviewPane rendering logic retain the stale iframe.
  • Why this is likely a bug: The behavior contradicts the PR's documented preview contract in content/guides/editor.md:272-277 and editor/README.md:416-421, which says existing output is stale and that the staged output commit preserves the last valid dist tree after a failed rebuild. The test first confirmed a real generation-zero iframe, then observed that the failed rebuild removed it, and a later successful request rendered generation 1. The recovery proves the fixture can render the preview and that the failure is tied to error-state handling. The local API route was intentionally used only to model a failed rebuild response; it did not modify application files or inject UI state beyond the documented failure condition. A targeted UI change to restore the prior preview data or apply the host's stale response is sufficient.
Relevant code

editor/ui/src/App.svelte:250-270

const previousData = preview.data;
    if (preview.data) preview.data = { ...preview.data, phase: 'running' };
    ...
    if (result.response.ok) {
      setPreview(result.data as PreviewState);
    } else if ((result.data as ErrorResponse).error === 'watch_daemon_active') {
      if (previousData) preview.data = previousData;
      noteWatchRefusal();
    } else preview.status = `Preview host failed: ${(result.data as ErrorResponse).error ?? 'request failed'}. Existing output is not current.`;

editor/ui/src/components/PreviewPane.svelte:28-31

{#if preview.data && (preview.data.phase === 'success' || preview.data.phase === 'stale')}
  <a class="button-link" href={preview.data.preview_url} target="_blank" rel="noreferrer">Open preview in new tab</a>
{/if}

editor/ui/src/components/PreviewPane.svelte:61-70

{#if preview.data && (preview.data.phase === 'success' || preview.data.phase === 'stale')}
  <div class="preview-frame">
    <iframe title="Boris site preview" src={`${preview.data.preview_url}&generation=${preview.data.generation}`} ...></iframe>
  </div>
{:else}
  <p>No valid Boris preview output is available yet.</p>
{/if}

editor/src/preview.zig:69-78

if (self.exit_code == 0) {
    ...
    self.generation += 1;
} else {
    self.phase = if (hasIndex(io, self.project_root)) .stale else .failed;
    self.used_stderr_fallback = true;
    self.setStderrSummary(execution.stderr);
}
Evidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.

**Medium severity — Failed rebuild hides the last valid preview**

**What failed:** The failed rebuild removed the visible preview iframe and left the status at running instead of showing a failed or stale state with the generation-zero frame. The recorded UI message was 'Preview host failed: request failed. Existing output is not current.'

- **Impact:** When a preview rebuild fails, editors lose the last working preview and cannot inspect it until another rebuild succeeds. This interrupts the preview workflow but does not alter the saved project or its data.
- **Steps to reproduce:**
  1. Open the editor with an existing stale generation-zero preview so the preview frame is visible.
  2. Click Rebuild preview and make the rebuild request fail.
  3. Check the Preview pane after the failure.
  4. Run a successful rebuild and check that generation 1 renders, confirming that the earlier missing frame was not a permanent fixture limitation.
- **Stub / mock content:** The test used local in-page API mocks for the editor endpoints and a tokened preview URL. The preview mock supplied stale generation 0, a failed rebuild response, and then a successful generation-1 response; no production services, credentials, or application files were used.
- **Code analysis:** The direct defect is in editor/ui/src/App.svelte:250-270. rebuildPreview stores previousData and optimistically sets preview.data.phase to running at lines 255-256. If the request succeeds, setPreview replaces that state at lines 260-262. If the request is a watch-daemon refusal, the function restores previousData at lines 263-268, but the general error branch at line 269 only changes preview.status and never restores preview.data or applies the failed/stale response returned by the host. Consequently preview.data remains phase=running. The Preview pane then follows editor/ui/src/components/PreviewPane.svelte:28-31 and 61-70: it hides the new-tab link and iframe unless phase is success or stale, and shows 'No valid Boris preview output is available yet' for running. This matches the observed missing iframe. The host-side implementation in editor/src/preview.zig:62-78 preserves the last dist tree and reports stale when an index exists after a failed build, so the UI is discarding the valid state rather than the build output being unavailable. The smallest fix is to handle every non-watch rebuild error by restoring previousData when it exists, or by converting the returned failed/stale PreviewState into preview.data, while setting the failure message; do not leave the optimistic running state behind. This keeps the generation unchanged on failure and lets the existing PreviewPane rendering logic retain the stale iframe.
- **Why this is likely a bug:** The behavior contradicts the PR's documented preview contract in content/guides/editor.md:272-277 and editor/README.md:416-421, which says existing output is stale and that the staged output commit preserves the last valid dist tree after a failed rebuild. The test first confirmed a real generation-zero iframe, then observed that the failed rebuild removed it, and a later successful request rendered generation 1. The recovery proves the fixture can render the preview and that the failure is tied to error-state handling. The local API route was intentionally used only to model a failed rebuild response; it did not modify application files or inject UI state beyond the documented failure condition. A targeted UI change to restore the prior preview data or apply the host's stale response is sufficient.

**Relevant code:**

`editor/ui/src/App.svelte:250-270`

~~~svelte
const previousData = preview.data;
    if (preview.data) preview.data = { ...preview.data, phase: 'running' };
    ...
    if (result.response.ok) {
      setPreview(result.data as PreviewState);
    } else if ((result.data as ErrorResponse).error === 'watch_daemon_active') {
      if (previousData) preview.data = previousData;
      noteWatchRefusal();
    } else preview.status = `Preview host failed: ${(result.data as ErrorResponse).error ?? 'request failed'}. Existing output is not current.`;
~~~

`editor/ui/src/components/PreviewPane.svelte:28-31`

~~~svelte
{#if preview.data && (preview.data.phase === 'success' || preview.data.phase === 'stale')}
  <a class="button-link" href={preview.data.preview_url} target="_blank" rel="noreferrer">Open preview in new tab</a>
{/if}
~~~

`editor/ui/src/components/PreviewPane.svelte:61-70`

~~~svelte
{#if preview.data && (preview.data.phase === 'success' || preview.data.phase === 'stale')}
  <div class="preview-frame">
    <iframe title="Boris site preview" src={`${preview.data.preview_url}&generation=${preview.data.generation}`} ...></iframe>
  </div>
{:else}
  <p>No valid Boris preview output is available yet.</p>
{/if}
~~~

`editor/src/preview.zig:69-78`

~~~zig
if (self.exit_code == 0) {
    ...
    self.generation += 1;
} else {
    self.phase = if (hasIndex(io, self.project_root)) .stale else .failed;
    self.used_stderr_fallback = true;
    self.setStderrSummary(execution.stderr);
}
~~~

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@itoqa Thank you — we checked this against the code and the observation is right.

rebuildPreview sets phase to running, then on a non-ok host response other than watch_daemon_active it only updates preview.status. PreviewPane hides the iframe unless phase is success or stale, so the last working frame disappears and the phase chip stays running. That matches the message you recorded (Preview host failed: request failed. Existing output is not current.).

Two nits so the card stays honest: this error branch is already on main (this PR did not change rebuildPreview), and a Boris rebuild that returns 200 with stale still keeps the frame via setPreview. Your mock hit the HTTP-failure path, which is the real hole.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import { token, launchOpenPath, api, elapsedLabel, hostErrorLabel, authorPathIssue, isLaunchOpenSafe } from './lib/api';
import { token, launchOpenPath, api, elapsedLabel, hostErrorLabel, authorPathIssue, isLaunchOpenSafe, defaultLaunchPath } from './lib/api';
import type {
Health,
Version,
Expand Down Expand Up @@ -141,6 +141,14 @@
} else {
buffer.editorStatus = `Launch open path ignored: ${launchOpenPath} is not an author-owned project file.`;
}
} else {
const fallback = defaultLaunchPath(filesResult.data.files, filesResult.data.last_open);
if (fallback) {
const opened = await openFile(fallback);
if (opened && recovery.ok && recovery.skipped > 0) {
buffer.editorStatus = `${recovery.skipped} recovery snapshot${recovery.skipped === 1 ? ' was' : 's were'} unreadable and ignored.`;
}
}
}
startHostWatch();
startDiskWatch();
Expand Down
Loading