Skip to content
16 changes: 16 additions & 0 deletions docs/acceptance/workspace-markdown-preview-20261022/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Workspace Markdown preview

Captured in Chrome at 1440 × 1000 using the production renderer and the `workbenchjourney` production API harness, with its offline model provider and a disposable project. The harness was restarted after rebuilding because it loads the UI bundle at startup.

Both captures open the same 657-byte `preview-notes.md` sample from Workspace files. It contains headings, a list, a table, a fenced code block, an HTTPS link, and a relative file link. Its returned content digest begins with `6d5357a52d91` in both captures.

- **Before** (`5892e371bea0a20596d9684ecd1778049a143700`, PR #442): the file is available only as numbered Markdown source.
- **After** (this PR): selecting Preview shows formatted content. Source remains the initial view.
- Searching for `PREVIEW_SEARCH_MARKER` while previewing and clicking its result returned to Source, highlighted line 24, and displayed the position notice.
- The preview's HTTPS link retained `target="_blank"` and `rel="noreferrer noopener"`; the relative file link rendered as an inert span.

These are unmodified browser screenshots.

![Before: Markdown source only](before-markdown-source.png)

![After: formatted Markdown preview](after-markdown-preview.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions docs/acceptance/workspace-search-line-20261022/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Workspace search line navigation

Captured in Chrome at 1440 × 1000 using the production renderer and the `workbenchjourney` production API harness, with its offline model provider and disposable project.

The temporary project contained a 120-line `search-example.txt`; line 90 contained `SEARCH_HIT_90`. Both captures follow the same interaction: open workspace files, search for the marker, then click its result.

- **Before** (`fc0935d6fc03ca7abccbd148ce52d26c2bc154f8`): the file opened at the top without a highlighted line; the file scroll position was 0.
- **After** (`0567844fddc33093c61d5093149e8767e39db164`): line 90 was highlighted and visible, with the position notice; the file scroll position was 2702.

These are unmodified browser screenshots. The harness was restarted after rebuilding because it loads the UI bundle at startup.

![Before: search result opens at the top](before-open-hit.png)

![After: search result opens at matching line 90](after-open-hit.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions docs/acceptance/workspace-search-retry-20261022/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Workspace search retry

Baseline: `b6e89e8c481bb38293d584a9204b36916528c834` (#448).

Both PNGs are unmodified 1440 × 1000 Chrome captures of the production renderer and API served by `cmd/workbenchjourney`. The harness uses a disposable workspace and an offline model; `retry-notes.md` is a temporary document containing `SEARCH_RECOVERY_PROOF`.

For each capture, only the browser's workspace-search requests were temporarily aborted. After the error appeared, the interruption was removed and a direct request confirmed that the real API returned HTTP 200 with the expected file. No API response was replaced.

- `before-retry-stuck.png`: clicking Search again with the unchanged keyword sends no request and leaves the error visible.
- `after-retry-recovers.png`: the same action sends a request, receives HTTP 200, and displays the result without editing the keyword.

The button and Enter regression cases both fail against the baseline and pass with the fix. The Explorer and Markdown preview suites pass together (21 tests); TypeScript and the production build pass. OCR completed the changed production component with zero findings; its default path rules exclude test files.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions web/src/components/workspace-explorer-preview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { APIClient } from "../api/client";
import type { WorkspaceExplorerView } from "../api/types";
import { V2RecoveryProvider } from "../v2/recovery-storage";
import { FileEditDraftProvider } from "./file-edit-drafts";
import { WorkspaceExplorer } from "./workspace-explorer";

vi.mock("@monaco-editor/react", () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) =>
<textarea aria-label="Monaco proposal editor" onChange={(event) => onChange(event.target.value)} value={value} />,
}));
vi.mock("../lib/monaco-local", () => ({ configureLocalMonaco: vi.fn() }));

describe("WorkspaceExplorer Markdown preview", () => {
it("switches the returned content between source and safe formatted Markdown", async () => {
const snapshot = markdownSnapshot([
"# Project notes", "", "**Ready**", "", "- Review changes", "",
"[web](https://example.com/docs) [relative](./notes.md) [local](file:///secret) [unsafe](javascript:alert(1))",
"", "![tracking](https://example.com/pixel.png)", "<script>alert(1)</script>",
"", "SESSION_SECRET=[REDACTED:secret]",
].join("\n"));
const client = previewClient(snapshot);
const user = userEvent.setup();
const view = renderExplorer(client);

expect(await screen.findByText("# Project notes")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Preview" }));
const preview = screen.getByRole("region", { name: "Markdown preview" });
expect(within(preview).getByRole("heading", { name: "Project notes" })).toBeInTheDocument();
expect(within(preview).getByText("Ready").tagName).toBe("STRONG");
expect(within(preview).getByRole("listitem")).toHaveTextContent("Review changes");
expect(within(preview).getByRole("link", { name: "web" })).toHaveAttribute("target", "_blank");
expect(within(preview).getByRole("link", { name: "web" })).toHaveAttribute("rel", "noreferrer noopener");
for (const label of ["relative", "local", "unsafe"]) {
expect(within(preview).getByText(label).tagName).toBe("SPAN");
}
expect(preview.querySelector("script, img")).not.toBeInTheDocument();
expect(preview).toHaveTextContent("SESSION_SECRET=[REDACTED:secret]");
expect(screen.getByText("1 redacted")).toBeInTheDocument();
expect(view.container.querySelector(".explorer-file-lines")).not.toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Source" }));
expect(screen.getByText("# Project notes")).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Markdown preview" })).not.toBeInTheDocument();
expect(client.workspaceExplore).toHaveBeenCalledTimes(1);
});

it.each(["README.MD", "notes.markdown", "guide.mdown", "notes.txt"])("offers preview only for Markdown paths: %s", async (path) => {
const client = previewClient({ ...markdownSnapshot(), path });
renderExplorer(client, { initialPath: path });
await screen.findByText("# Project notes");
expect(Boolean(screen.queryByRole("button", { name: "Preview" }))).toBe(path !== "notes.txt");
});

it("returns to source for search results and later citation lines", async () => {
const client = previewClient(markdownSnapshot());
const user = userEvent.setup();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const explorer = (initialLine: number) => <QueryClientProvider client={queryClient}>
<WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" initialLine={initialLine} />
</QueryClientProvider>;
const view = render(explorer(1));
await user.click(await screen.findByRole("button", { name: "Preview" }));
expect(screen.queryByText("Positioned at line 1")).not.toBeInTheDocument();
await user.type(screen.getByLabelText("Search Workspace evidence"), "matched");
await user.click(screen.getByRole("button", { name: "Search Workspace" }));
await user.click(await screen.findByRole("button", { name: /README.md.*line 2/ }));
expect(await screen.findByText("Positioned at line 2")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument();
expect(view.container.querySelector('[data-line="2"]')).toHaveClass("is-highlighted");

await user.click(screen.getByRole("button", { name: "Preview" }));
view.rerender(explorer(3));
expect(await screen.findByText("Positioned at line 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Source", pressed: true })).toBeInTheDocument();
});

it("references the same snapshot and retains truncation and provenance in preview", async () => {
const snapshot = { ...markdownSnapshot(), truncated: true, total_bytes: 1024 };
const onSelectReference = vi.fn();
const client = previewClient(snapshot);
const user = userEvent.setup();
renderExplorer(client, { onSelectReference });
await user.click(await screen.findByRole("button", { name: "Reference this file" }));
await user.click(screen.getByRole("button", { name: "Preview" }));
await user.click(screen.getByRole("button", { name: "Reference this file" }));
expect(onSelectReference.mock.calls).toEqual([[snapshot], [snapshot]]);
expect(screen.getByText("truncated")).toBeInTheDocument();
expect(screen.getByText("workspace_file / File source record")).toBeInTheDocument();
expect(screen.getByText("b".repeat(12))).toBeInTheDocument();
expect(client.workspaceExplore).toHaveBeenCalledTimes(1);
});

it("keeps an editing draft across returning to preview and switching views", async () => {
const snapshot = { ...markdownSnapshot(), redaction_count: 0 };
const client = Object.assign(previewClient(snapshot), {
hasFileEditProposals: true,
issueFileEditProposalSource: vi.fn().mockResolvedValue({
protocol_version: "file_edit_proposal.v1", run_id: "run-1", workspace_id: "workspace-1",
path: snapshot.path, content: snapshot.content, content_sha256: snapshot.provenance.content_sha256,
source_handle: "B".repeat(43), expires_at: "2099-07-18T00:05:00Z", editable: true, file_write: false,
}),
createFileEditProposal: vi.fn(),
});
const user = userEvent.setup();
renderExplorer(client, { runID: "run-1" });
await user.click(await screen.findByRole("button", { name: "Preview" }));
await user.click(screen.getByRole("button", { name: "Edit proposal" }));
const editor = await screen.findByLabelText("Monaco proposal editor");
expect(screen.queryByRole("group", { name: "File view" })).not.toBeInTheDocument();
await user.clear(editor);
await user.type(editor, "# Retained draft");
await user.click(screen.getByRole("button", { name: "Close file proposal editor" }));
await user.click(screen.getByRole("button", { name: "Keep draft" }));
expect(screen.getByRole("region", { name: "Markdown preview" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Source" }));
await user.click(screen.getByRole("button", { name: "Preview" }));
await user.click(screen.getByRole("button", { name: "Edit proposal" }));
await waitFor(() => expect(screen.getByLabelText("Monaco proposal editor")).toHaveValue("# Retained draft"));
expect(client.createFileEditProposal).not.toHaveBeenCalled();
});
});

function markdownSnapshot(content = "# Project notes\nmatched text\nthird line\n"): WorkspaceExplorerView {
return {
protocol_version: "workspace_explorer.v1", workspace_id: "workspace-1", path: "README.md",
kind: "file", entries: [], content, total_bytes: content.length, returned_bytes: content.length,
truncated: false, redaction_count: 1, root_path_exposed: false,
provenance: { version: "context_provenance.v1", source_kind: "workspace_file", source_ref: "README.md",
content_sha256: "b".repeat(64), instruction_authorized: false },
};
}

function previewClient(snapshot: WorkspaceExplorerView) {
return Object.assign({} as APIClient, {
baseURL: "http://localhost:8765/api/v1",
workspaceExplore: vi.fn().mockResolvedValue(snapshot),
workspaceSearch: vi.fn().mockResolvedValue({ results: [{ path: "README.md", line: 2,
match_kind: "content", snippet: "matched text", content_truncated: false,
provenance: snapshot.provenance }], truncated: false }),
});
}

function renderExplorer(client: APIClient, props: Partial<Parameters<typeof WorkspaceExplorer>[0]> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}><V2RecoveryProvider client={client} scopeID="preview-tests"><FileEditDraftProvider>
<WorkspaceExplorer client={client} workspaceID="workspace-1" initialPath="README.md" {...props} />
</FileEditDraftProvider></V2RecoveryProvider></QueryClientProvider>);
}
34 changes: 34 additions & 0 deletions web/src/components/workspace-explorer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.explorer-file > .explorer-file-summary {
flex-wrap: wrap;
padding: 6px 0;
}

.explorer-view-switch {
display: flex;
gap: 4px;
margin-right: auto;
}

.explorer-view-switch .compact-command[aria-pressed="true"] {
background: var(--prayu-control);
color: var(--prayu-text);
font-weight: 600;
}

.explorer-markdown-preview {
max-height: calc(100vh - 280px);
min-height: 280px;
padding: 16px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--prayu-surface-solid);
color: var(--prayu-text);
font-size: 13px;
line-height: 1.6;
}

.explorer-markdown-preview .safe-markdown pre {
min-height: 0;
max-height: none;
}
Loading
Loading