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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>) 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
Expand Down Expand Up @@ -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`.
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions app/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions components/SessionSidebar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OpenInNewTabIcon \/>;/);
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;/);
Expand Down
17 changes: 17 additions & 0 deletions components/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -94,6 +95,7 @@ import {
FolderIcon,
MessageIcon,
ForkIcon,
OpenInNewTabIcon,
PencilIcon,
PinIcon,
PinOffIcon,
Expand Down Expand Up @@ -302,6 +304,7 @@ const SESSIONS_SECTION_MIN_HEIGHT = 120;
const SIDEBAR_HEADER_HEIGHT = 36;

const SESSION_ACTION_LABEL_KEYS: Record<SessionMenuActionId, string> = {
"open-new-tab": "sidebar.openInNewTab",
pin: "sidebar.pin",
unpin: "sidebar.unpin",
rename: "sidebar.rename",
Expand All @@ -315,6 +318,7 @@ const SESSION_ACTION_LABEL_KEYS: Record<SessionMenuActionId, string> = {

function sessionActionIcon(id: SessionMenuActionId): ReactNode {
switch (id) {
case "open-new-tab": return <OpenInNewTabIcon />;
case "pin": return <PinIcon />;
case "unpin": return <PinOffIcon />;
case "rename": return <PencilIcon />;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading