From dd5bd9277e583cbcf5fc991f5bd958bc97139d0c Mon Sep 17 00:00:00 2001 From: cg <91442850+wowayou@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:23:07 +0800 Subject: [PATCH 1/4] feat(sessions): open sidebar sessions in a new browser tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A saved session's title in the sidebar is now a real link (?session=), so middle-click and Ctrl/Cmd-click open it in a new browser tab while the current tab keeps its session. A plain click and Enter still select it in place without a page load. The row's own click handler is unchanged. The row menu (right-click and ⋯) gets "Open in new tab" (T), just before Delete; never first, since a menu's first item takes focus when it opens. Transient sessions keep their button and get no link. When focus is put back into a row it now goes to its first a[href] or button, so a keyboard Fork focuses the copy's link, not its archive button. Closes #1167 --- AGENTS.md | 3 +- README.md | 11 +- app/sidebar.css | 14 +- components/SessionSidebar.test.mjs | 15 ++ components/SessionSidebar.tsx | 17 +++ components/SessionTree.test.mjs | 63 +++++++- components/SessionTree.tsx | 71 ++++++--- components/SidebarIcons.tsx | 11 ++ docs/agents/sessions.md | 2 + e2e/README.md | 8 + e2e/run.mjs | 15 ++ e2e/session-navigation.mjs | 235 +++++++++++++++++++++++++++++ lib/i18n/messages/en.ts | 1 + lib/i18n/messages/zh-CN.ts | 1 + lib/i18n/messages/zh-TW.ts | 1 + lib/session-links.test.mjs | 56 +++++++ lib/session-links.ts | 40 +++++ lib/sidebar-actions.test.mjs | 50 +++++- lib/sidebar-actions.ts | 13 +- 19 files changed, 586 insertions(+), 41 deletions(-) create mode 100644 e2e/session-navigation.mjs create mode 100644 lib/session-links.test.mjs create mode 100644 lib/session-links.ts diff --git a/AGENTS.md b/AGENTS.md index 4f26970bb5..0f48f75989 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 @@ -218,7 +219,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, 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/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, 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/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 7570943bce..48b37994c3 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 b35dfdb750..86728a23c1 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 b57a86f119..7e61f9a349 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 35a9c7c6fd..32f80f4aa7 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 8317446f7e..1cfb109e40 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 9aa6dba3db..0666b966f2 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 90f9dc1694..dde16a2d6e 100644 --- a/docs/agents/sessions.md +++ b/docs/agents/sessions.md @@ -117,6 +117,8 @@ Tree model (`buildSessionTree()`, pure; `components/SessionTree.tsx` virtualizes - 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. - 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 `