diff --git a/AGENTS.md b/AGENTS.md index 9805215e1..fb0132bdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ lib/ session-ui-state-shared.ts client-safe types and rules of that state: request parsing, apply, undo snapshot sidebar-prefs.ts per-browser sidebar tab, group expand choices, pinned-section collapse, files-section fold, folders found outside git sidebar-actions.ts session row menu entries, a project group's name entries and rename request, fork refusal messages + session-links.ts a session's same-instance link (?session=) and the plain-click rule the sidebar keeps in the page new-session-context.ts client-safe: what the project/worktree pickers show (project, worktrees, project list), lag-safe contextForCwd normalize.ts normalizeToolCalls(): file-format vs our toolCall field names types.ts shared TypeScript types @@ -219,7 +220,7 @@ hooks/ Design decisions and traps live in `docs/agents/`, one note per area. Read every note whose files a change touches before making it. Add new notes to the area's file, not here. -- [sessions.md](docs/agents/sessions.md): AgentSession lifecycle and shutdown, fork vs in-session branching, the sidebar's on-disk Fork, session file rewrites, toolCall normalization, SSE reconnect and tool events, transcript system / usage / context-edit entries, running-state polling, custom-message collapsing, streaming markdown block by block, the session sidebar (toolbar row and its brand, project groups, their order and display names, pins, archive, new-session project adoption, the files below the sessions or in a tab), the project/worktree bar above a fresh composer (the header: brand, then the bar) and what it carries across the remount, exported HTML, the extension status bar and its `command:` buttons. Files: `lib/rpc-manager.ts`, `lib/session-reader.ts`, `lib/session-fork*.ts`, `lib/normalize.ts`, `lib/session-tree.ts`, `lib/session-ui-state*.ts`, `lib/sidebar-prefs.ts`, `lib/sidebar-actions.ts`, `lib/new-session-context.ts`, `hooks/useAgentSession.ts`, `hooks/useSessionUiState.ts`, `hooks/useSidebarMotion.ts`, `hooks/useGroupDrag.ts`, `hooks/useFilesPlacement.ts`, `hooks/useResizablePanel.ts`, `app/api/agent/**`, `app/api/sessions/**`, `components/SessionSidebar.tsx`, `components/PiWebTitle.tsx`, `components/SessionTree.tsx`, `components/SidebarMenu.tsx`, `components/SidebarToast.tsx`, `components/SidebarIcons.tsx`, `components/ProjectWorktreePicker.tsx`, `components/NewSessionContextBar.tsx`, `components/WorktreeCreateForm.tsx`, `handleNewSession`, `handleSelectSession` and the bar's handlers in `components/AppShell.tsx`, `components/BranchNavigator.tsx`, `components/MessageView.tsx`, `components/MarkdownBody.tsx`, `lib/markdown-blocks.ts`, `components/CodemodeToolView.tsx`, `components/ExtensionStatusBar.tsx`, `components/ExtensionWidgets.tsx`. +- [sessions.md](docs/agents/sessions.md): AgentSession lifecycle and shutdown, fork vs in-session branching, the sidebar's on-disk Fork, session file rewrites, toolCall normalization, SSE reconnect and tool events, transcript system / usage / context-edit entries, running-state polling, custom-message collapsing, streaming markdown block by block, the session sidebar (toolbar row and its brand, project groups, their order and display names, pins, archive, new-session project adoption, the files below the sessions or in a tab), the project/worktree bar above a fresh composer (the header: brand, then the bar) and what it carries across the remount, exported HTML, the extension status bar and its `command:` buttons. Files: `lib/rpc-manager.ts`, `lib/session-reader.ts`, `lib/session-fork*.ts`, `lib/normalize.ts`, `lib/session-tree.ts`, `lib/session-ui-state*.ts`, `lib/sidebar-prefs.ts`, `lib/sidebar-actions.ts`, `lib/session-links.ts`, `lib/new-session-context.ts`, `hooks/useAgentSession.ts`, `hooks/useSessionUiState.ts`, `hooks/useSidebarMotion.ts`, `hooks/useGroupDrag.ts`, `hooks/useFilesPlacement.ts`, `hooks/useResizablePanel.ts`, `app/api/agent/**`, `app/api/sessions/**`, `components/SessionSidebar.tsx`, `components/PiWebTitle.tsx`, `components/SessionTree.tsx`, `components/SidebarMenu.tsx`, `components/SidebarToast.tsx`, `components/SidebarIcons.tsx`, `components/ProjectWorktreePicker.tsx`, `components/NewSessionContextBar.tsx`, `components/WorktreeCreateForm.tsx`, `handleNewSession`, `handleSelectSession` and the bar's handlers in `components/AppShell.tsx`, `components/BranchNavigator.tsx`, `components/MessageView.tsx`, `components/MarkdownBody.tsx`, `lib/markdown-blocks.ts`, `components/CodemodeToolView.tsx`, `components/ExtensionStatusBar.tsx`, `components/ExtensionWidgets.tsx`. - [tools.md](docs/agents/tools.md): tool presets and Chat only, exact system prompts, tool exposure, the codemode / tool-search / mcp built-ins, the read-only MCP policy, the Code mode and PowerShell `defaultTools` switches. Files: `lib/tool-presets.ts`, `lib/tool-preset-preference.ts`, `lib/chat-only.ts`, `lib/exact-system-prompt.ts`, `lib/builtin-extensions.ts`, `lib/mcp-read-only-policy.ts`, `lib/codemode-settings.ts`, `lib/powershell-settings.ts`, `lib/global-settings-file.ts`, `app/api/agent/new/route.ts`, `app/api/tools/settings/route.ts`, tool selection in `lib/rpc-manager.ts`. - [mcp-runtime.md](docs/agents/mcp-runtime.md): the per-session MCP host (when servers register and connect, reported states, trust read on every sync, idle release); `/mcp` in the composer. Files: `lib/mcp-host.ts`, `lib/mcp-transport.ts`, `lib/mcp-status.ts`, `lib/mcp-command.ts`, `lib/mcp-config-key.ts`, MCP wiring in `lib/rpc-manager.ts` and `lib/builtin-extensions.ts`, `/mcp` handling in `hooks/useAgentSession.ts`. - [mcp-settings.md](docs/agents/mcp-settings.md): Settings › MCP reads without running anything, masking, the trust dialog's server list, row states, notices, Code mode choice, trust from Settings, Escape stacking, every `mcp.json` write and undo. Files: `app/api/mcp/route.ts`, `app/api/project-trust/route.ts`, `lib/mcp-config-read.ts`, `lib/mcp-config-file.ts`, `lib/mcp-override.ts`, `lib/mcp-undo.ts`, `lib/mcp-secrets.ts`, `lib/mcp-server-display.ts`, `lib/mcp-json-error.ts`, `lib/project-trust.ts`, `lib/regular-file.ts`, `lib/stacked-dialog.ts`, `lib/settings-navigation.ts`, `components/McpConfig.tsx`, `components/mcp-config-helpers.ts`, `components/ProjectTrustDialog.tsx`, `components/SettingsPanel.tsx`. diff --git a/README.md b/README.md index 7570943bc..48b37994c 100644 --- a/README.md +++ b/README.md @@ -135,11 +135,12 @@ window.addEventListener("pi-web:session-row-contextmenu", (event) => { The detail object contains `id`, `path`, `cwd`, optional `name`, pointer coordinates, and a `refresh()` callback for actions that change the session list. If no listener cancels the extension event, Pi Web opens its built-in -session menu (pin, rename, fork, mark read or unread, archive, delete) at the -pointer instead of the browser's native context menu. The row's `⋯` button -always opens the built-in menu and does not dispatch the event. Sessions not -yet saved to disk get no built-in menu, so the native one still appears for -them. This hook is browser-side and independent of Pi agent extensions. +session menu (pin, rename, fork, mark read or unread, archive, open in new +tab, delete) at the pointer instead of the browser's native context menu. +The row's `⋯` button always opens the built-in menu and does not dispatch the +event. Sessions not yet saved to disk get no built-in menu, so the native one +still appears for them. This hook is browser-side and independent of Pi agent +extensions. ### Extension Session Liveness diff --git a/app/sidebar.css b/app/sidebar.css index b35dfdb75..86728a23c 100644 --- a/app/sidebar.css +++ b/app/sidebar.css @@ -84,11 +84,13 @@ cursor: pointer; } -.session-tree button:focus { +.session-tree button:focus, +.session-tree a.session-tree-main:focus { outline: none; } -.session-tree button:focus-visible { +.session-tree button:focus-visible, +.session-tree a.session-tree-main:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } @@ -239,6 +241,14 @@ text-align: left; } +/* A saved session's main control is a link (middle-click, Ctrl/Cmd-click open + it in a new tab): it looks exactly like the button it replaced. */ +a.session-tree-main { + color: inherit; + text-decoration: none; + cursor: pointer; +} + .session-tree-unread { position: relative; flex: none; diff --git a/components/SessionSidebar.test.mjs b/components/SessionSidebar.test.mjs index b57a86f11..7e61f9a34 100644 --- a/components/SessionSidebar.test.mjs +++ b/components/SessionSidebar.test.mjs @@ -954,6 +954,21 @@ test("right-click lets the downstream hook claim the row before the built-in men assert.equal((treeSource.match(/onContextMenu=/g) ?? []).length, 1); }); +test("Open in new tab opens the clicked row's session link and leaves this tab alone", () => { + // The row the menu was opened on, not the selected session; the same link as its title. + assert.match(source, /case "open-new-tab": openFamilyInNewTab\(family\); break;/); + const body = callbackBody("openFamilyInNewTab"); + assert.match(body, /if \(family\.root\.transient\) return;\s*window\.open\(sessionDeepLink\(family\.root\.id\), "_blank", "noopener,noreferrer"\);/); + // Synchronous, so the browser takes it as the user's: no await before it, + // and no selection, cwd move or request of its own. + assert.doesNotMatch(body, /await|async|fetch\(|setSelectedCwd|handleSelect|onSelectSession|setMenu/); + // It is a built-in menu item with its own label and icon, in both menus + // (right-click and ⋯ share sessionMenuItems()). + assert.match(source, /"open-new-tab": "sidebar\.openInNewTab",/); + assert.match(source, /case "open-new-tab": return ;/); + assert.equal((source.match(/sessionMenuEntries\(row\.context, row\.status\)/g) ?? []).length, 1); +}); + test("does not expose disk-backed actions for transient sessions", () => { assert.match(callbackBody("openRowMenu"), /if \(row\.status\.transient\) return;/); assert.match(callbackBody("startRename"), /if \(family\.root\.transient\) return;/); diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index 35a9c7c6f..32f80f4aa 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -4,6 +4,7 @@ import { useEffect, useImperativeHandle, useLayoutEffect, useState, useCallback, import type { SessionInfo } from "@/lib/types"; import { listSessionFamilies, type SessionFamily } from "@/lib/session-family"; import { dispatchSessionRowContextMenu } from "@/lib/session-row-context-menu"; +import { sessionDeepLink } from "@/lib/session-links"; import { getProjectActivity, getRecentProjects } from "@/lib/project-groups"; import { workspaceKeyOf } from "@/lib/workspace-memory"; import { @@ -94,6 +95,7 @@ import { FolderIcon, MessageIcon, ForkIcon, + OpenInNewTabIcon, PencilIcon, PinIcon, PinOffIcon, @@ -302,6 +304,7 @@ const SESSIONS_SECTION_MIN_HEIGHT = 120; const SIDEBAR_HEADER_HEIGHT = 36; const SESSION_ACTION_LABEL_KEYS: Record = { + "open-new-tab": "sidebar.openInNewTab", pin: "sidebar.pin", unpin: "sidebar.unpin", rename: "sidebar.rename", @@ -315,6 +318,7 @@ const SESSION_ACTION_LABEL_KEYS: Record = { function sessionActionIcon(id: SessionMenuActionId): ReactNode { switch (id) { + case "open-new-tab": return ; case "pin": return ; case "unpin": return ; case "rename": return ; @@ -1904,9 +1908,22 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio } }, [loadSessions, showToast, t]); + // Open in new tab (the row menu's T): the row's session in a browser tab of + // its own, by the same link its title carries. This tab's selection, cwd and + // file tabs stay as they are, and nothing is asked of the server here: the + // new tab loads the session as a reload would. Synchronous inside the menu's + // click or key press, so the browser counts it as the user's and does not + // block it as a popup. Its return value says nothing: with noopener the + // browser returns null whether the tab opened or not. + const openFamilyInNewTab = useCallback((family: SessionFamily) => { + if (family.root.transient) return; + window.open(sessionDeepLink(family.root.id), "_blank", "noopener,noreferrer"); + }, []); + const runSessionAction = (id: SessionMenuActionId, row: SessionRow, shiftKey: boolean) => { const { family } = row; switch (id) { + case "open-new-tab": openFamilyInNewTab(family); break; case "pin": setFamilyPinned(family, true); break; case "unpin": setFamilyPinned(family, false); break; case "rename": startRename(family); break; diff --git a/components/SessionTree.test.mjs b/components/SessionTree.test.mjs index 8317446f7..1cfb109e4 100644 --- a/components/SessionTree.test.mjs +++ b/components/SessionTree.test.mjs @@ -191,8 +191,9 @@ test("an idle session row shows its short time, branch and both actions", () => const html = rowMarkup(render({ rows: [sessionRow(root)] }), "session:group:idle"); assert.match(html, /^
/); // Nothing before the title: it gets the room; the time sits at the right. - // The branch after its icon, in the rows' own type (the tooltip keeps the ⑂ glyph). - assert.match(html, / + {status.transient ? ( + // No file yet: a link would open a session no other tab can load. + + ) : ( + // A real link, so middle-click, Ctrl/Cmd-click and the browser's own + // gestures open the session in another tab. A plain click (or Enter) + // stays here and selects it as before; never `target`, which would + // send plain clicks away too. + { + // The row's handler must not select it again, nor on a modified click. + event.stopPropagation(); + if (!isPlainActivation(event)) return; + event.preventDefault(); + handlers.current.onSelectFamily(family); + }} + > + {mainContent} + + )} {!status.transient && ( {quickAction && (context === "archive" ? ( diff --git a/components/SidebarIcons.tsx b/components/SidebarIcons.tsx index 9aa6dba3d..0666b966f 100644 --- a/components/SidebarIcons.tsx +++ b/components/SidebarIcons.tsx @@ -122,6 +122,17 @@ export function ForkIcon(props: SidebarIconProps) { ); } +/** An arrow leaving a box: a session row's "Open in new tab" (a browser tab, not a copy). */ +export function OpenInNewTabIcon(props: SidebarIconProps) { + return ( + + + + + + ); +} + export function TrashIcon(props: SidebarIconProps) { return ( diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md index 67a7ee62e..6a24c0934 100644 --- a/docs/agents/sessions.md +++ b/docs/agents/sessions.md @@ -110,14 +110,16 @@ Tree model (`buildSessionTree()`, pure; `components/SessionTree.tsx` virtualizes - Rows: nothing before the title; the right column shows the awaiting-input dot (amber, breathing), else the running spinner, else the unread dot, else the short time (a pinned row's project, an archived row's archive time). A running row keeps its spinner beside ⋯ on hover. Hover and selection are a rounded box inset from both edges; titles are 12px `--text` (the file tree's size), the selected one 500, and the selected row (in the archive view too) has a 3px accent bar on its box's left edge, 7px clear of its top and bottom (a 14px bar; 12px on a phone, 20px), a `::before` of the absolutely positioned row, so its rounded corners never clip it; a delete confirmation's red frame has none. A worktree's branch is meta in the rows' own type (11px dim, a 10px `BranchIcon` kept inline so the ellipsis still cuts the name; the tooltip keeps `⑂`), as a pinned row's project is (no code type). A classic scrollbar takes width on the right only (Chromium's and Safari's styled one is 10px, 6px on a coarse pointer; an overlay one none): `SessionTree` measures it into `--session-tree-scrollbar`, and the right inset shrinks by it, so rows end 6px in or on the gutter's edge, whichever is further in, while the left inset stays 6px (moving it too put the tree's two lines 4px right of everything else's in Chromium). It also puts the width on the sidebar's root (only a tree with a box: not one under the archive view or in a hidden tab), where the files below the sessions read it for their right edge. A row's time (or spinner, dot) ends 4px inside its box, where a folded group's summary ends: neither the row nor its always-rendered actions span has a gap, each shown action brings its own 2px (`margin-left`), so the hidden ones leave the time on the edge. The list keeps the scrollbar's room even when everything fits (`scrollbar-gutter: stable`; `overflow-y: scroll` in Safari before 18.2), so rows never narrow when it starts to scroll. - Row keys are unique and stable (`session::`, `group:`, `more:`, `empty:`, and fixed keys for the pinned header, the two spacers (`pinned-spacer`, `footer-spacer`) and the footer); focus tracking and `keepMounted` go by them. - Menus (`SidebarMenu`) and the toast live in `SessionSidebar`, above the virtualized rows, so a row scrolled away keeps its open menu (the project/worktree picker of the files tab and of the composer's bar uses `SidebarMenu` for its own two menus); the row with focus, a menu, a rename or a delete confirmation stays mounted (`keepMounted`). Menus are portaled to `document.body`: the mobile drawer's `transform` would pin a `position: fixed` menu to the drawer. The archive view is a second tree; the main one stays mounted underneath, the two in one grid cell (`.sidebar-sessions-views`, `grid-area: 1 / 1`; the archive opaque in `--bg-panel` and stacked above the tree for good, `position: relative; z-index: 1`: the tree's rows are positioned and paint over an unpositioned view, which only its animation lifted, so the frame its entrance ended in flashed the tree). Going in and out moves, in both layouts and on phones (`useSidebarMotion()`, `archiveViewState()` in `hooks/useSidebarMotion.ts`; `setArchiveView()` starts it on a change only, and View, Back and a fork's open all go through it): in, the archive slides from 14px right and fades in over the tree (`.is-entering`, 180ms ease-out), the tree `inert` under it until the view is in, then `hidden` as before; out, the tree is back at once and the archive slides and fades out to the right over it (`.is-leaving`, 180ms ease-in, `forwards`), still mounted with its rows (`archiveRows` is built while `archiveMounted`, else it would leave empty), `inert` and `pointer-events: none`, so nothing in it is clicked twice and a click lands on the tree, until its own `animationend` (not one bubbling from a row) unmounts it. A phase that gets no end event (a sidebar not shown: a hidden tab, a closed drawer) ends 300 ms after its start (`SIDEBAR_MOTION_MS` + `SIDEBAR_MOTION_GRACE_MS`). Under `prefers-reduced-motion` no phase starts and the CSS has no animation: the views swap at once, as before. -- Focus: a menu returns it to the opener it was given when it opened (the parent drops `returnFocusTo` with the menu); a row's ⋯ or a group's buttons are `display: none` by then unless hovered, selected or focused, so `menuFocusReturnTarget()` falls back to the row's first focusable control (its main button, a group's toggle). Controls that go away under the keyboard hand focus on: the archive's Back button when it opens, the footer link (or the selected tab, or with no tabs the toolbar row's search) after Back, the family's row after a toast's Undo or a delete Cancel (`focusIfLost()`, only when focus fell to ``). The archive's two moves make the view they leave `inert` before it is hidden or gone, and Chromium blurs an inert element only at its next frame, after the effect that moves focus has run: `focusIfHidden()` counts focus on something without a box or inside `[inert]` as lost too. +- Focus: a menu returns it to the opener it was given when it opened (the parent drops `returnFocusTo` with the menu); a row's ⋯ or a group's buttons are `display: none` by then unless hovered, selected or focused, so `menuFocusReturnTarget()` falls back to the row's first focusable control (its main link or transient-session button, a group's toggle). Controls that go away under the keyboard hand focus on: the archive's Back button when it opens, the footer link (or the selected tab, or with no tabs the toolbar row's search) after Back, the family's row after a toast's Undo or a delete Cancel (`focusIfLost()`, only when focus fell to ``). The archive's two moves make the view they leave `inert` before it is hidden or gone, and Chromium blurs an inert element only at its next frame, after the effect that moves focus has run: `focusIfHidden()` counts focus on something without a box or inside `[inert]` as lost too. - The running poll keeps the previous `Set` while the ids are unchanged (`sameIdsOr()`): tree rows are memoized by identity, and a new Set every 2.5 s would rebuild them all. - Toggling a group (Alt+click: every group follows it), show more and the group ⋯ never change the cwd. Only "Open in Files" in a group menu switches the project on purpose (and shows the files: `revealFiles()`); row clicks go through `handleSelectSessionFromList()`, which moves the cwd to the session's worktree. - Fork (F) is offered in the group, pinned and archive views, also while the family runs (`sessionMenuEntries()`). One per session at a time (`forkingIdsRef`): F again on a session whose fork is under way does nothing, another session's Fork goes ahead. The answer goes through `openForkedRef`, never a click-time closure: `openForked()` drops the main tree's saved scroll and closes the archive view (the copy has no flags; pins and archive are never inherited), opens the copy's group if the user collapsed it (re-inserted as the newest choice), selects it through `handleSelectSessionFromList()` (as a row click: cwd to its worktree, AppShell adopts the project), loads the list without `force` (the route already invalidated it), and asks the main tree to reveal `session:group:`; a toast says "Forked “…”" with the copy's name, its ellipsis cutting only what precedes the suffix: `splitBeforeForkSuffix()` passes the suffix and the closing quote as the toast's `tail` (`SidebarToastData.tail`, a span that does not shrink); an unnamed copy is cut as other toasts are (`shortTitle()`). On a phone the drawer stays open for that pick (`SelectSessionOptions.keepSidebarOpen`, honoured by `AppShell.handleSelectSession`): the copy has its source's transcript and, but for the suffix, its title, so only the revealed row and the toast, both in the drawer, show that it happened. If the selection changed during the request, the user stays put: the copy is inserted, marked unread (so it shows beyond its group's limit) and the toast offers Open, which opens it as a row click does (the drawer closes). Not `AppShell.handleSessionForked`: its `prev` spread assumes the source is the selected session. A refusal toasts `forkFailureMessage()`'s text; `not_found` reloads the list. - Moving a group: drag its header (`hooks/useGroupDrag.ts`), or Move up / Move down in its ⋯ menu (disabled at the band's edges; `adjacentProjectMove()` on the project as the tree has it now, which may have been pinned elsewhere while the menu was open). Both call `moveProject()`, which refuses a move across bands, sends `move-project` with the band's `unorderedKeysByBand` (only that band's: a project dropped between two unsaved groups lands where it was seen; past 500, which only a full list or a move before the first save leaves, the bottom 500, which land where they show) and asks the tree to reveal `group:` through the generic reveal below. That request also keeps the row mounted through the commit in which the menu closes: a group moved past a tall neighbour would otherwise leave the virtual window, and the menu's focus return would find its ⋯ disconnected. - The drag uses pointer events (HTML5 drag and drop never starts from a touch on iOS and cannot scroll a virtualized list). A mouse picks the group up after 4px (`DRAG_START_PX`), touch and pen after a 350 ms long-press (`LONG_PRESS_MS`) without moving more than 8px: a swipe still scrolls, a pen stroke is no drag, a plain click or tap still toggles (pointerdown never prevents anything; "+" and ⋯, other buttons, Ctrl and a second pointer start nothing). Once picked up, the header's own `touchmove` listener, added in `GroupRowView`'s effect with `{ passive: false }` before any touch, cancels the moves so the list holds still (added after touchstart, the browser would already own the gesture; React's `onTouchMove` is passive; on the whole list it would make every scroll wait for the main thread); an uncancelable move ends the drag only if the list really scrolled (Chrome sends some inside its slop). Escape and `contextmenu` are taken over (capture phase, `preventDefault`) only once picked up, so Escape on a mere press still reaches `handleGlobalEscape`; a context menu before that ends the press. A mouse move with `buttons === 0` ends a press whose release was missed; a mouse or pen takes pointer capture when the drag starts. The click after a drop, a cancelled drag or a long-press is eaten (`onClickCapture` on the scroll box, by event time; the next press clears it). Drop targets come from the model (`groupBlocks()`, `groupDropAt()`), never the DOM: rows not mounted, collapsed groups and "show more" count as laid out, a pointer over the pinned section or the footer means the band's first or last place, and a drop where the group already is does nothing. A block runs from its header to the bottom of its last row (sessions, "show more", the empty note), with the spacer heights around it (`gapAbove`, `gapBelow`: 0 next to another group). Groups do not fold up while dragging (the layout would move under the pointer): the block is dimmed in place (its own rows only), a 2px accent line with a 6px dot at its left end marks the drop in the middle of the gap it marks (on the edge between two groups, mid-spacer after the pinned section or before the footer; its `lineY` is at least 3px, so the dot stays whole at the list's top), and a ghost follows the pointer. The ghost is a one-line pill with the project's name (22px high, at most 70% of the tree, ellipsis), shorter than a header so it fits beside the line: on every update `placeGhost()` puts it with `ghostTopFor()` (`lib/session-tree.ts`), pointer first: its bottom 8px (mouse) or 28px (touch, pen: `GHOST_GAP_PX`) above the pointer, and moved only as far as it takes to keep that gap from the pointer, 8px from the line (`GHOST_LINE_GAP_PX`) and inside the list: to the nearest top that does. A line far from the pointer (up to half an expanded block) leaves the pill by the pointer, so it never parks beside the line nor jumps when the target changes; a line just above the pointer puts it above the line; at the list's top it goes below the pointer (and below a line just under it). A line scrolled out of view counts as none; only a list too short to hold the pill clear of both puts it over the pointer, and over the line only when no top in the list keeps clear of it. Horizontally it keeps to the rows' left inset (the header's left edge plus 4px), never under the finger. Its height comes from the CSS (`offsetHeight`), its width from the name. The ghost is a child of `.session-tree`, outside the scroll box: inside it, it would add to the scroll height and auto-scroll would chase it into empty space; auto-scroll (near either edge, up to 14px a frame) also stops at the rows' height. The dragged header stays in `keepMounted`. A drag is also cancelled by rows without its group (archived elsewhere), loading, a hidden tree (archive view, files tab) or a second pointer, and these wait for the release to eat its click; a blur, a hidden page or `pointercancel` end it at once. No `navigator.vibrate` (Chrome warns without a user activation, iOS has none) and no body `user-select` (headers have `user-select: none` and `-webkit-touch-callout: none`). -- `SessionTree`'s `reveal` (`SessionTreeReveal { id, at, rowKey, takeFocusFrom? }`) is the generic way to bring a row into view after a change put it elsewhere (a fork's copy, a moved group). Ids come from a counter in the parent (`treeRevealIdRef`), never from the last request, which is gone once handled. Until handled the row stays mounted (`keepMounted`). Each look at the rows goes through `revealStep()` (`lib/session-tree.ts`): scrolled to with `revealScrollTop()` (the show-more path's centring) when the row is there, dropped when its row has missed 3 rows updates or the request is more than 3 s old (`at`), found or not, so it never fires later when that row turns up for another reason. Either way the tree calls `onRevealHandled(id)` and the parent clears the request: the search results replace (unmount) the main tree while a query is typed, and a tree mounted again would take a request still held for a new one. Focus moves to the row's first button only when it fell to `` or `takeFocusFrom(active)` agrees; the fork passes "still in the source row, or where closing the archive put it" (`archiveCloseFocusRef`). Only the main tree gets it. +- `SessionTree`'s `reveal` (`SessionTreeReveal { id, at, rowKey, takeFocusFrom? }`) is the generic way to bring a row into view after a change put it elsewhere (a fork's copy, a moved group). Ids come from a counter in the parent (`treeRevealIdRef`), never from the last request, which is gone once handled. Until handled the row stays mounted (`keepMounted`). Each look at the rows goes through `revealStep()` (`lib/session-tree.ts`): scrolled to with `revealScrollTop()` (the show-more path's centring) when the row is there, dropped when its row has missed 3 rows updates or the request is more than 3 s old (`at`), found or not, so it never fires later when that row turns up for another reason. Either way the tree calls `onRevealHandled(id)` and the parent clears the request: the search results replace (unmount) the main tree while a query is typed, and a tree mounted again would take a request still held for a new one. Focus moves to the row's first control only when it fell to `` or `takeFocusFrom(active)` agrees; the fork passes "still in the source row, or where closing the archive put it" (`archiveCloseFocusRef`). Only the main tree gets it. - Right-click dispatches `pi-web:session-row-contextmenu` first (README, "Downstream Session Context Menu"); only an unclaimed event opens the built-in row menu at the pointer. The ⋯ button opens the built-in menu without dispatching, so there is always a way in. A transient (unsaved) session gets no built-in menu or actions: each would act on a missing file. Renaming or delete-confirming rows ignore right-click. +- A saved session's title is a real link, `?session=` (`sessionDeepLink()`, `lib/session-links.ts`), so the browser opens it in another tab (#1167). It is only the row's main control (`a.session-tree-main`, styled as the button it replaced), never the whole row: the row's archive and ⋯ buttons are not nested in it. A plain primary click or Enter (`isPlainActivation()`) is the page's: the link stops it from reaching the row, cancels the navigation and selects the session as before (`onSelectFamily`, no page load). Ctrl/Cmd, Shift, Alt and middle-click are the browser's: nothing is cancelled, no `onAuxClick`, no `window.open()`, no `target` (a plain click would leave the tab too). The row's own click handler is unchanged: any click on its empty space, or on a transient row's button, selects (Ctrl/Cmd-click too), as before. The link is relative on purpose: it keeps origin and path (a sub-path deployment) and drops every other parameter and the hash, since a stale `?cwd=` beats `session` in `getInitialNavigation()`; the explicit `session` beats the new tab's remembered one (`withTabOpen()`). A transient session keeps a `