---
title: Peek viewer (look at a file without leaving what you were doing)
---

# Peek Viewer (preview files inline + navigate sibling images)

## What it is

### What it is

A side panel (drawer on desktop, full-screen sheet on mobile) that previews a single file without leaving the chat. You open it by clicking a file path or image link that an agent printed in its message. Code files render with syntax highlighting; images render through the shared `ImageCanvas` (pinch-to-zoom on mobile, click-to-zoom on desktop).

For files whose extension maps to a **renderable format**, a **Type** toggle in the header flips between the raw source and a rendered preview. The peek viewer renders **four** formats through its shared `PreviewRenderer`: **Markdown** (`.md` / `.mdx`), **SVG** (`.svg`, sanitized markup), **Mermaid diagrams** (`.mmd` / `.mermaid`, lazy-loaded), and **LaTeX / KaTeX** (`.tex` / `.latex`, sanitized HTML). The toggle is hidden for any other extension. **HTML is the deliberate exception**: `.html` / `.htm` peeks render as **raw code only**, never through an iframe — the toggle is suppressed for them so the peek viewer's security posture is unchanged. (The sandboxed-iframe HTML render path lives in the separate File Explorer file viewer, not the peek overlay.)

**PDFs are the other special case.** A `.pdf` peek hands the whole body to Omniscio's built-in PDF viewer (paginated, zoom, selectable text, find, convert-to-text, **Print** — see [media-link-open.md](media-link-open.md)). A PDF has no raw view, so the header shows **no Type toggle** and **no lines · chars · tokens line** for it — those only make sense for text, and for a PDF they would describe its base64 encoding rather than the document. The header keeps the file name, path copy, Share / Send-to-agent and Close.

The peek viewer also has **sibling-image navigation**. When you click an image link inside an agent message that contains _multiple_ image links, those other images become "siblings" of the one you opened. You can flip through them in place — left/right arrow buttons flank the image, a small `N / M` counter sits at the bottom-center, and `ArrowLeft` / `ArrowRight` on the keyboard work too. On mobile you swipe left to advance and right to go back. Navigation wraps at edges, so the last image's "next" goes back to the first. When the agent message has only one image, the arrows, counter, and swipe handlers stay hidden entirely — the viewer behaves exactly like the pre-navigation peek viewer.

## Where to find it

From a file link or path wherever it appears in the app — the peek opens as a slide-over on top of what you were already reading, so you close it and you are back.

## How it behaves

### How to use it

1. **Open a chat session** with an agent that produces file paths or image links in its replies (any provider).
2. **Click an image link** in an agent message. The peek viewer slides in from the right (desktop) or up from the bottom (mobile) showing that image.
3. **If the same agent message has more image links**, three things appear on top of the image:
   - **Left chevron** at the middle-left edge — previous image.
   - **Right chevron** at the middle-right edge — next image.
   - **Position counter** at the bottom-center, shaped like `2 / 5`.

   The counter stays hidden when the message has only one image link.

4. **Navigate the siblings** any of three ways:
   - **Click** the on-screen left / right chevron buttons.
   - **Press** `ArrowLeft` / `ArrowRight` on the keyboard. (No modifier — plain arrows. Holding Ctrl/Cmd/Alt/Shift falls through to OS / app shortcuts as normal.)
   - **Swipe** horizontally on the image area on mobile. Swipe left to advance to the next image, swipe right to go back. Vertical scrolls and pinch-to-zoom are unaffected.

5. **Wrap-around** is automatic. The first image's "previous" jumps to the last; the last image's "next" jumps back to the first. There's no edge stop and no edge dimming — it's a circular ring.
6. **Close the viewer** with the X button, the desktop backdrop click, the Android back gesture (mobile), or `Escape` on the keyboard. Sibling state is forgotten — opening a different image link starts a fresh sibling group.

### What "sibling" means

A sibling is **another image link in the same agent message bubble**. The viewer walks the DOM up from the link you clicked to the nearest message-bubble container (`[data-msg-summary]`) and collects every other image link inside that bubble in document order. Image links in _different_ messages are never siblings, even if they appear adjacent on screen. This matches what users intuitively expect: "show me the rest of the images this agent message mentioned."

Operator-uploaded images (the chips you attach in the composer) already had their own lightbox with its own arrow nav — that lightbox is unchanged. Sibling-image navigation lives _only_ in the peek viewer that opens from agent-message links.

### When navigation is suppressed

The arrow buttons, counter, keyboard handler, and swipe handler are all gated together so the viewer never gets caught in a half-state where one input works and another doesn't:

- **Single image.** Hidden entirely (not greyed out / disabled). A one-image preview looks identical to the pre-Phase-7 peek viewer.
- **Editing the file.** When you click the pencil icon to edit a text file's contents, arrow keys belong to the textarea (cursor movement). The keyboard handler yields. The arrow buttons + counter still _show_ because images aren't editable, but in practice editing only applies to text files where there are no sibling controls anyway.
- **A confirmation dialog is open.** If you have unsaved edits and hit "Discard?", the inner confirm dialog owns the keys until you answer.
- **Multi-touch / pinch.** If you put a second finger down at the start of a touch, or add it mid-gesture, the swipe is aborted — pinch-to-zoom belongs to the `ImageCanvas` zoom engine, not nav. The next single-finger touch starts cleanly.
- **Zoomed in.** While the image is zoomed past fit, sibling-swipe is suspended entirely — a one-finger drag pans the zoomed picture instead of flipping to the next image. `ImageCanvas` reports rest↔zoomed crossings via `onZoomedChange`, which clears `swipeNavEnabled`; it resets to fit (and re-enables swipe) on each `key={filePath}` remount.
- **Vertical drag dominance.** A swipe must travel **more than 50 pixels horizontally** _and_ more horizontally than vertically. A 50px-exact drag stays in the no-op band. This means scrolling the page on mobile (mostly vertical) doesn't accidentally flip images.
- **Modifier keys.** Plain `ArrowLeft` / `ArrowRight` only. `Ctrl+ArrowLeft`, `Shift+ArrowRight`, etc. fall through to the OS / app — they're never intercepted.
- **Focus inside an `<input>`, `<textarea>`, or `[contenteditable]`.** Arrow keys belong to the focused field for text editing; the peek handler yields.

### Counter accessibility

The `N / M` counter at the bottom-center is wrapped in `role="status" aria-live="polite"` with `aria-label="Image N of M"`. Screen readers will announce the new position when you flip to a sibling.

### Find in the file (Ctrl+F)

When you're viewing a **text or Markdown file** (not an image or PDF), press **Ctrl+F** (Cmd+F on macOS) to open a browser-style **find bar** at the top-right of the content. Type to highlight every match with a **"N of M"** counter; **Enter** jumps to the next match and **Shift+Enter** to the previous one (or use the up/down chevrons), scrolling the active match into view. **Esc** or the ✕ closes the find bar and clears the highlights — and while it's open, Esc closes the _find bar_ first, not the whole viewer.

Find works in **both** the raw view and the rendered **Markdown preview**, and it searches only the file that's currently open. It's a desktop feature (a phone has no Ctrl key) and is available while _viewing_, not while editing. When the peek viewer is open, its Ctrl+F takes priority — the Settings find bar never opens behind it. Under the hood it shares the same find engine as the Settings and in-session find, so highlighting and matching behave identically across the app.

### Mobile loading + caching

On mobile / web (any browser context — no Electron preload) image bytes stream over the `/file?path=...&token=...` HTTP route on the Omniscio web server, _not_ over the WebSocket IPC channel that desktop uses. Two things follow:

- **The spinner shows for the entire fetch.** Until the underlying `<img>` reports `complete`, the peek body keeps `isLoading = true`. A semi-opaque LoadingPanel overlays the image area so you have continuous visual feedback during the download — multi-MB photos over a slow Tailscale link no longer look frozen. Once the image decodes, `ImageCanvas` fires its `onLoadComplete` callback and the store flips `isLoading = false`, which removes the spinner overlay. If the load fails (404, network drop, decode error), the same path fires `onLoadError` and you see the red error UI instead.
- **Sibling navigation reuses the browser cache.** The `/file` route returns `Cache-Control: private, max-age=300, must-revalidate` plus a weak ETag (`W/"<size>-<mtimeMs>"`) and a `Last-Modified` stamp. On every subsequent view the browser issues a conditional GET (`If-None-Match` or `If-Modified-Since`) and the server short-circuits to a bodyless 304 when the file hasn't changed. So flipping back-and-forth across siblings touches the network for headers only — the bytes come from the disk cache. First-pass nav still pays the network cost; the win is on the second visit per sibling and on every revisit during a single peek session.

`If-None-Match` takes precedence over `If-Modified-Since` (per RFC 7232 §6) when both headers arrive on the same request — so you don't need to worry about the two cache validators racing. The HTTP-date format only has 1-second resolution, so the server truncates `mtimeMs` to whole seconds before comparing it against `If-Modified-Since` to avoid spurious 200s on sub-second-modified files.

Stale-resolution guards prevent rapid sibling flicks from showing the wrong image: the store's `openEpoch` counter increments on every open and the IPC path discards in-flight resolutions whose epoch no longer matches; ImageCanvas's `onLoad` / `onError` callbacks pass the exact `src` they observed and the store ignores the call when `state.content !== loadedSrc`. So the previous image's late-firing `onload` after you've already navigated past it is a no-op — never a UI flicker.

### Mobile text viewing + editing

On a phone the peek viewer is a full read-and-edit surface, not a read-only sheet:

- **Raw text wraps for prose.** In **Raw** mode the file body wraps to the panel width (`whitespace-pre-wrap` + `break-words`) for **markdown and plain text** — any file with no detected highlight `language` — so long lines and long paths / URLs stay readable without sideways scrolling. This holds on **both desktop and mobile**. **Code and data files** (a detected highlight language other than markdown — `.ts`, `.json`, `.yaml`, `.tex`, …) keep horizontal scroll (`overflow-x-auto`) on desktop so indentation / alignment stays intact and you scroll to read, which the wider drawer affords; on a phone every file wraps (a small screen can't scroll sideways comfortably). The wrap-vs-scroll choice keys off the file's detected `language` (`isMobile || !language || language === 'markdown'`). The **Type** preview (Markdown / SVG / …) is unaffected; it already reflows.
- **Editing is available on mobile.** The **Edit** pencil and the **Save / Cancel** controls show on phones, not just desktop. Saving goes through the same `FILES_WRITE_ABSOLUTE` path desktop uses, which already works over the mobile web route (e.g. a Tailscale tunnel). The editor textarea uses a **16px** font on mobile (vs the denser desktop size) so iOS Safari doesn't auto-zoom the page when you tap into the field, and the pencil gets a larger tap target.
- **The Android back-gesture protects unsaved edits.** Backing out of an in-progress edit with unsaved changes shows the same **"Discard changes?"** confirmation the X button and `Escape` use, instead of silently dropping the edit. The mobile back handler re-arms its history sentinel on "keep editing" so a later back still dismisses the viewer.

Editing stays gated to **raw, non-truncated** text files on every platform — a truncated file (>1 MB) stays view-only so a partial save can't overwrite the real file, and `.html` peeks stay raw-only for the iframe-security reason above.

**Concurrent edits don't clobber each other.** The two globally-editable docs (`CLAUDE.md` / `MEMORY.md`) are editable from desktop **and** a paired phone, so two surfaces can have the same file open at once. To stop one save from silently wiping the other's, the editor records the file's on-disk timestamp when it loads and echoes it back on **Save**; if the file changed on disk in between (the other surface saved first), the save is **refused** with _"This file changed on disk since you opened it. Copy your edits, then reopen it to load the latest version."_ — your unsaved text stays in the editor so you can copy it and reopen the newest version. A first-ever save with no recorded timestamp still goes straight through.

### Mobile crash recovery (stale-chunk reload)

`FilePeekOverlay` is `React.lazy()`-loaded — its module is fetched on first open. On a long-lived mobile tab, that fetch can fail after a desktop rebuild has replaced hashed chunk filenames: the tab is still running yesterday's entry chunk that references hash `XYZ`, but the server only has `ABC` now. Three layers cooperate so the user-facing tap "just works" without showing a loading state or perceived crash:

### Layer 1 — boot-time chunk prefetch (primary)

Both bootstrap paths in [App.tsx](/src/renderer/src/App.tsx) call `prefetchFilePeekChunk()` from [mobile-prefetch.ts](/src/renderer/src/lib/mobile-prefetch.ts) during `requestIdleCallback` (with `setTimeout(200)` fallback for Safari). This fire-and-forget dynamic import warms the FilePeekOverlay chunk **before the user ever taps a link**.

On a fresh tab where the chunk hashes still match the server, the prefetch hits the cache and the first tap is instant. On a stale tab where the chunk hashes have changed, the prefetch is exactly what trips `vite:preloadError` — but it trips at idle time (seconds into the page lifecycle, while the user is reading the inbox), which routes the reload through Layer 2 silently in the background. By the time the user actually taps a markdown link, the page has already reloaded and the fresh chunk is in cache.

This is the primary fix for the "tap → blank → reload" perceived crash. The user never sees a tap-then-reload sequence because the reload happens at boot, not at tap.

### Layer 2 — preload-reload-guard (background recovery)

When `vite:preloadError` fires (from the boot-time prefetch OR from any other lazy boundary that runs before the prefetch warmed the chunk), the [preload-reload-guard](/src/renderer/src/lib/preload-reload-guard.ts) catches it and reloads the page once per page lifecycle so the fresh `index.html` picks up the new chunk hashes. The store also persists the click intent before any async work so a tap that does race the prefetch can be replayed after the reload — gated by three independent safeguards so a stale entry never spuriously re-opens a peek hours later. See "Write / Reload / Restore paths" below.

### Layer 3 — visible Suspense fallback (edge-case safety net)

If the user somehow taps a markdown link before the prefetch resolves (extremely fast tap within the first ~200ms of boot, or before idle-callback fires), the React.lazy boundary briefly mounts [`FilePeekLoadingFallback.tsx`](/src/renderer/src/components/ui/FilePeekLoadingFallback.tsx) — a panel-shaped placeholder matching the eventual viewer's shape: same right-slide drawer on desktop, same full-screen sheet on mobile, same close button, plus a spinner and the file name pulled from the store. Tapping a link registers within one frame, before the chunk fetch even starts.

The fallback uses only entry-chunk-resident dependencies (framer-motion, lucide-react, Zustand, `useIsMobile`) so adding it pulls nothing new into the startup bundle. Adding markdown-vendor weight here would defeat the lazy-load purpose. In practice the fallback is rarely visible because the prefetch wins the race, but it's the floor of the user experience when the prefetch hasn't finished yet.

### Layer 2 internals — write / reload / restore

#### Write path (open)

`useFilePeekStore.openFile(filePath, lineNumber, siblings)` writes `{filePath, lineNumber, siblings, ts}` to `sessionStorage` under `peek-intent` synchronously, BEFORE the IPC await and BEFORE the React.lazy import boundary. (`close` clears the entry so a clean close doesn't trigger restore.) `ts` is the millisecond timestamp at write time — used by the TTL gate on restore.

#### Reload path (preload-reload-guard)

When `vite:preloadError` fires, the guard:

1. Writes a `last-preload-error` breadcrumb (`{url, message, timestamp}`) — fired even when the guard suppresses the reload, so a SECOND chunk failure on the same lifecycle is still recorded.
2. Checks the once-per-page `chunk-reload` cookie. If set, returns without reloading (loop cap).
3. Otherwise: sets `chunk-reload`, sets `peek-restore-armed=1`, and calls `location.reload()`. The arm flag is set ONLY when reload actually fires — a suppressed second failure must NOT re-arm restoration.

The `chunk-reload` cookie is cleared in the next page's `load` listener so a later (post-success) chunk failure can reload again.

#### Restore path (boot, browser-only)

[main.tsx](/src/renderer/src/main.tsx) calls `restorePeekIntent()` after `installPreloadReloadGuard()`. The function walks five gates, each self-clearing the state it consumes so nothing carries forward to the next lifecycle:

1. **Diagnostic breadcrumb** — read+log+delete `last-preload-error`. `console.warn('[peek-restore] preload error from previous lifecycle:', diag)` so the next investigation can correlate the reload with the failed chunk URL. Cleared regardless of the rest of the gates.
2. **Arm flag** — read+delete `peek-restore-armed`. Manual F5 / Cmd+R / tab-restore preserves sessionStorage but never sets the flag, so a manual refresh must NOT replay. The flag is read AND deleted before the intent gate so a stale flag from a browser quirk can't haunt the session.
3. **Intent entry** — read+delete `peek-intent`. If absent, return. Anti-loop: delete BEFORE attempting replay so a chunk that persistently fails self-clears instead of restore-fail-restore-fail forever. **The arm-flag enforcement (`armed !== '1' → return`) runs AFTER the intent delete** so a manual F5 still one-shot-clears the stale intent rather than carrying it forward into a future reload-driven boot.
4. **Shape validation** — `filePath` must be a non-empty string. Malformed entries are dropped silently.
5. **TTL** — entries with `ts` older than 60 seconds are dropped. Defends against an arm flag that survives a quirky navigation flow we didn't anticipate.

Replay calls `openFile(filePath, lineNumber, siblings)` with a module-scoped `suppressNextPersist` flag set so the synchronous `writePeekIntent` inside doesn't re-write the entry we just deleted. The flag is single-shot — a subsequent user action (sibling nav, fresh open) writes a new intent normally.

The whole path is no-op in Electron (`isElectron` short-circuits both write and restore) — chunks load from disk on desktop and the failure mode does not exist.

### Limitations and v1 notes

- **Sibling group is captured at click time.** If the agent appends a new image to the message after you opened the peek viewer (rare but possible during streaming), the peek viewer won't see it — close and reopen to refresh.
- **No "open all in a gallery view"** — sibling nav is in-place only. There's no contact-sheet UI.
- **Image links in code blocks aren't siblings.** The walker scopes to the message bubble (`data-msg-summary`) but skips inert markdown that doesn't render as anchors. If an image path appears as fenced code text rather than an image link, it's not part of the sibling set.
- **No telemetry** for sibling-nav input methods. Existing peek viewer events don't track input modality, so adding only sibling-nav events would be inconsistent. If we later add peek viewer telemetry generally, sibling-nav method (`button` / `keyboard` / `swipe`) is the natural attribute to include.

## For agents

### How it works

Three layers cooperate:

1. **Discovery** — [`findSiblingImageHrefs`](/src/renderer/src/lib/sibling-images.ts) walks up from the clicked anchor to its `[data-msg-summary]` ancestor (set on every agent / operator bubble in `MessageBubble.tsx`), then collects every `<a>` inside that bubble whose `data-href` (or `href`) ends in a renderable image extension (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `ico`). Pure DOM read — no IPC, no store subscription. Returns siblings in document order including the clicked one (so the click handler doesn't have to think about "where am I in this list").

2. **Click wiring** — [`agent-markdown-helpers.tsx`](/src/renderer/src/components/ui/agent-markdown-helpers.tsx)'s `CopyableLink` onClick captures `e.currentTarget` (the anchor element) and, if the link looks like an image, calls `findSiblingImageHrefs(anchorEl)` and forwards the result as the third arg to `useFilePeekStore.openFile(absPath, lineNumber, siblings)`. Non-image links never call the helper, so text-file peeks stay zero-cost.

3. **Store + navigation** — [`useFilePeekStore`](/src/renderer/src/stores/file-peek-store.ts) tracks `siblings: string[]`, `siblingIndex: number | null`, and an `epoch` counter that increments on every open. `nextSibling` / `prevSibling` resolve the next href, increment `epoch`, and call the same internal load path as `openFile` — so the arrow-button click, the keyboard handler, and the swipe handler all funnel through one code path. The `epoch` guard means a fast-flick (next, next, next before the first load resolves) drops every stale load result; only the latest wins.

4. **UI overlay** — [`FilePeekOverlay`](/src/renderer/src/components/ui/FilePeekOverlay.tsx) reads `siblings` + `siblingIndex` and gates `canNavigateSiblings = isImage && siblings.length > 1 && siblingIndex !== null`. When false, the entire block of arrows + counter + swipe handlers is unmounted (not just `disabled`). The keyboard listener registers conditionally on the same gate; the touch handlers no-op via `swipeNavEnabled`.

### Path resolution: scope gate + worktree discovery

**Which project does the click scope against?** For an absolute link the path names its OWN project, so the click resolves the _owning_ project from the path itself — the registered project whose folder contains it — **even when you are viewing a different session** — and scopes the read there. This is what lets a link to a file that lives in another registered project open from THAT project instead of being rejected against the session you happen to be looking at (the cross-project card-link fix). Only when the path is under no known project does it fall back to the active session, then any real project. A relative link has no owning project, so it stays scoped to the active session. See a-file-click-never-requires-an-active-session in the [agent-markdown link-detection contract](/.claude/memory/contracts/agent-markdown-link-detection-contract.md).

When an agent's message links to an absolute path (most commonly a file inside a git worktree the agent created), Omniscio has to decide whether that path is "in scope" — i.e. close enough to the active project that the user shouldn't have to manually grant access. The decision happens in [`scopeAbsolutePathToProject`](/src/main/ipc/file-handlers.ts) and runs four layers from cheapest to most authoritative:

1. **Project workdir + Omniscio-recorded worktrees + `~/.claude/`.** Always allowed. Standard case.
2. **Convention carve-out.** Three workdir-anchored conventions for sibling/in-repo worktree storage are trusted implicitly, even when Omniscio has no DB row for the worktree:
   - `<workdir>-worktrees/<branch>/` — Omniscio's own dash-suffix sibling
   - `<workdir>.worktrees/<branch>/` — Claude Code period-suffix sibling
   - `<workdir>/.worktrees/<branch>/` — Claude Code in-repo dotfile dir
3. **Cross-convention read fallback.** If the original path scope-passes but the file doesn't exist on disk, Omniscio swaps the convention at the first matching segment and tries up to two alternates — so a link the agent wrote with the dash convention still opens when the file actually lives under the period convention.
4. **`git worktree list` discovery (Layer 3).** For arbitrary layouts the convention list doesn't anticipate (e.g. `<parent-of-workdir>/.worktrees/<branch>/` — a 4th convention some agents invented), Omniscio shells out to `git -C <projectWorkDir> worktree list --porcelain` and trusts every worktree path git itself reports. Results are cached for 30 seconds per project so the shell-out doesn't hammer git on every click. Silent fallback if git is missing on PATH, the project isn't a repo, or the spawn throws — Layers 1–3 still apply.

The directory has to actually be a git-listed worktree for Layer 3 to allow it — directory existence alone is not enough. A folder literally named `<project>.worktrees-malicious/` is rejected because `path.relative` enforces segment boundaries; bare prefix matching would be a security hole.

**Windows: a WSL / Git-Bash `/mnt/<drive>/…` link is normalized first.** An agent that derives a link from a Bash `pwd` (the audit orchestrators do) emits e.g. `/mnt/m/Agent Orchestrator/…` for a file that physically lives at `M:\Agent Orchestrator\…`. The Windows main process can never `stat` the POSIX form, so `FILES_READ_ABSOLUTE` runs `wslPathToWindows` (`/mnt/<drive>/…` → `<DRIVE>:\…`, **win32 only**, single-letter mounts only) before the layers above — the real file then resolves through the SAME scope gate. It widens nothing (the translated path is still scope- and existence-checked, exactly like every other alternate). Without it a `/mnt/…` link scope-failed and, even after a grant, dead-ended at "Failed to read file" because the granted `/mnt/…` path still doesn't exist on disk.

The user does not need to grant access to agent-created worktrees following any of these layouts. The grant-on-the-fly confirm banner ("This file is outside your project's allowed folders — always allow folder?") only appears for paths outside every layer.

**On mobile / a paired phone the outside-scope banner is read-only.** The grant IPC (`FILES_GRANT_AD_HOC_ACCESS`) is blocked on the web/WS bridge by design (a paired phone must not widen the desktop's file-access scope), so the "Open this once" / "Always allow folder" buttons render **only in the desktop app**. On a phone the banner shows the path plus an "open it from the desktop app" note and a single **Close** — before this, tapping the grant buttons on a phone hit the blocked channel and re-armed the banner every tap, an infinite loop.

### When the resolved path scope-passes but the file isn't there

Scope passing is not the same as the file existing. The renderer commits to **one** base when it turns a relative link into an absolute path (`session.worktreePath ?? agentDetectedWorkdir ?? project.folderPath`), and if it picks the "wrong" base for how the agent wrote the href, the absolute path is syntactically valid (scope-passes) but points at nothing.

**The two session bases stand on their own.** They are real absolute paths, so a project row that hasn't loaded into the renderer yet does not stop the click. Previously the project was checked _first_, so a missing project row discarded a perfectly good worktree and the click fell back to a project-folder-only search that can never see a sibling worktree — dead-ending at "Failed to read file" with the file sitting in the worktree the whole time (the [chapter-9 postmortem](/.claude/memory/postmortems/file-peek-missing-project-row-postmortem.md)). A project that IS loaded but is a **virtual** one — a sentinel like `__inbox__`, with no folder on disk — still stops resolution, and the click says it can't be opened from here rather than guessing a path. The read handler (`FILES_READ_ABSOLUTE`) recovers by trying alternates — first match that scope-passes **and** exists wins:

1. **Convention-swap** — swap `-worktrees` ↔ `.worktrees` ↔ `/.worktrees`.
2. **Main→worktree rebase** — a path under the main checkout re-based onto each git-reported worktree (covers a worktree-relative href resolved against the main root).
3. **Worktree→main unnest** — the mirror of (2). When the link was written relative to the **main root** (`.worktrees/<branch>/docs/x.md`, how the file looks from the project root) but the base was the **worktree**, the join **doubles** the segment: `<worktree>/.worktrees/<branch>/docs/x.md`. This re-anchors the worktree-relative tail back at the project root, collapsing the duplication.
4. **Reaped-worktree → main checkout.** When the worktree the link points into was **landed and then cleaned up** (a merge auto-lander removes the worktree folder + branch after merging its work to the main branch), that folder is gone from disk and from `git worktree list` — so alternates (2) and (3), which only trust worktrees git still reports, skip it entirely. This one recognizes a path inside any **known worktree-storage folder** — the three conventions next to the project, **or a relocated worktree folder** configured in `worktree-locations.json` (Omniscio can keep worktrees on a dedicated drive, e.g. `L:\AMC-Worktrees`, and it reads that same config so recovery can't drift from where worktrees really live) — and re-anchors the tail at the main project folder (`<container>/<name>/<tail>` → `<workdir>/<tail>`), where the merged file now lives. It re-anchors **only** to a path inside the project folder and is tried **last**, so a live worktree always resolves first. This is why a link into a since-cleaned-up worktree opens the file's current copy instead of dead-ending on "Failed to read file" (or, before this, the "outside your folders" banner). **Read only** — saving a file is never redirected this way (a save to a dead worktree path fails rather than silently overwriting the main copy).
5. **Project memory note.** A link like `[Route builds through the pipeline](route-builds-through-the-pipeline.md)` inside a project's `MEMORY.md` (the AI's memory index) points at a bare note name. When an agent echoes that link into a chat message and you tap it, Omniscio joins it against the project folder — but those notes don't live in the repo at all. They live in the AI's **per-project memory folder** (`~/.claude/projects/<project>/memory/`). When every worktree alternate above misses, Omniscio looks up the note there by its filename and opens it. This only fires for markdown (`.md`) notes, only when the link was already pointing inside the project (an unrelated outside link is never redirected to a same-named note), and only as the **last** resort — so a real project file always wins. It needs no new permission: the AI memory folder is under `~/.claude/`, which the peek viewer already trusts. **Read only.**

Every alternate is re-checked through the same scope gate (no bypass — alternates (2)/(3) trust only git-reported worktrees, (4) only the known worktree-storage folders (the conventions under the project or a configured relocated one) and always re-anchors inside the project folder, (5) only the project's own `~/.claude/` memory folder and only for links already in project scope, and every candidate re-anchors inside a folder Omniscio already trusts), and the handler returns the **resolved** path — the viewer adopts it, so the header shows the real file and a later Edit→Save writes to it rather than the ghost double. If nothing resolves, the failure is logged (it used to be silent, which is why this class of bug historically left no trace) and the viewer shows "Failed to read file."

The **relative**-path read (`FILES_READ_FILE`, used when the renderer had no base to build an absolute path from) now behaves the same way: it logs the path it wanted and the folder it searched, and it reports **"Couldn't read the file — it no longer exists (moved or deleted)"** instead of the bare "Failed to read file". That handler only ever searches the project folder and its subfolders, so it cannot reach a sibling worktree — which is exactly why the renderer must produce the absolute path first.

## Related

### Related pages

- [chat-attachments.md](chat-attachments.md) — how operator-uploaded images get into a message in the first place.
- [project-docs-auto-injection.md](project-docs-auto-injection.md) — how `.claude/docs/` images become attachments on every send.

