Spaces:
Paused
Paused
Search: titles match before and beyond the transcript scan
Browse files- piweb/hf-design.patch +149 -31
piweb/hf-design.patch
CHANGED
|
@@ -25595,12 +25595,44 @@ index cf38be6..c08aaef 100644
|
|
| 25595 |
)}
|
| 25596 |
</button>
|
| 25597 |
{report && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25598 |
diff --git a/components/SearchCommandBar.tsx b/components/SearchCommandBar.tsx
|
| 25599 |
new file mode 100644
|
| 25600 |
-
index 0000000..
|
| 25601 |
--- /dev/null
|
| 25602 |
+++ b/components/SearchCommandBar.tsx
|
| 25603 |
-
@@ -0,0 +1,
|
| 25604 |
+"use client";
|
| 25605 |
+
|
| 25606 |
+// The conversation search as a command bar: one floating panel centred over
|
|
@@ -25612,7 +25644,7 @@ index 0000000..35c3c8e
|
|
| 25612 |
+// replaced the field that used to unfold in the sidebar.
|
| 25613 |
+
|
| 25614 |
+import { Dialog } from "@base-ui/react/dialog";
|
| 25615 |
-
+import { useEffect, useId, useMemo, useRef, useState } from "react";
|
| 25616 |
+import { useI18n } from "@/hooks/useI18n";
|
| 25617 |
+import { formatShortRelativeTime } from "@/lib/i18n/format";
|
| 25618 |
+import type { SessionInfo } from "@/lib/types";
|
|
@@ -25635,6 +25667,22 @@ index 0000000..35c3c8e
|
|
| 25635 |
+ return session.name || session.firstMessage || session.id;
|
| 25636 |
+}
|
| 25637 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25638 |
+/** The recent conversations: top-level ones (no subagents), not archived, newest first. */
|
| 25639 |
+export function recentConversations(sessions: readonly SessionInfo[], archived?: ReadonlySet<string>, limit = RECENT_LIMIT): SessionInfo[] {
|
| 25640 |
+ return sessions
|
|
@@ -25771,17 +25819,16 @@ index 0000000..35c3c8e
|
|
| 25771 |
+ >
|
| 25772 |
+ <span className="command-bar-row-line">
|
| 25773 |
+ <span className="command-bar-row-title">
|
| 25774 |
-
+ {/*
|
| 25775 |
-
+
|
| 25776 |
-
+
|
| 25777 |
-
+ : titleOf(session)}
|
| 25778 |
+ </span>
|
| 25779 |
+ {archivedSessionIds?.has(session.id) && <span className="command-bar-row-tag">{t("sidebar.archived")}</span>}
|
| 25780 |
+ <span className="command-bar-row-meta">
|
| 25781 |
+ {projectName(session)} · {formatShortRelativeTime(new Date(session.modified), locale)}
|
| 25782 |
+ </span>
|
| 25783 |
+ </span>
|
| 25784 |
-
+ {row.kind === "match" && !row.result.titleMatch && (
|
| 25785 |
+ <span className="command-bar-row-snippet">
|
| 25786 |
+ {row.result.before}<mark>{row.result.match}</mark>{row.result.after}
|
| 25787 |
+ </span>
|
|
@@ -34514,7 +34561,7 @@ index 926e355..4ceeb51 100644
|
|
| 34514 |
+- A models.json model on `anthropic-messages` (its own `api`, else its provider's) gets **Claude adaptive thinking** under Advanced settings › Compatibility, which writes pi's `compat.forceAdaptiveThinking` (built-in Claude 4.6+ models set it in generated metadata; pi never infers it from a custom model's id). Without it pi sends `thinking.type: "enabled"` with a token budget (`xhigh` and `max` clamp to high's 16384). Claude Opus 5.5 through a gateway answered that with an empty thinking block and no bytes at all until the answer began, so minutes of silent thinking ended in `terminated`; adaptive thinking streams the summary. Switching it off removes the model's key, or writes `false` when the provider's `compat` turns it on.
|
| 34515 |
- Fetch models (`lib/model-discovery.ts`) copies only capability fields whose meaning does not vary between gateways into the added entries: `contextWindow`, `maxTokens`, `reasoning: true`, and a `thinkingLevelMap` from an advertised effort list naming pi's own levels (others `null`, `off` never touched). Ambiguous fields (`max_tokens`, `default_effort`) and junk values are dropped (#856).
|
| 34516 |
diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md
|
| 34517 |
-
index 2fc11ef..
|
| 34518 |
--- a/docs/agents/sessions.md
|
| 34519 |
+++ b/docs/agents/sessions.md
|
| 34520 |
@@ -42,7 +42,7 @@ On mount `useAgentSession` loads the history, then `GET /api/sessions/[id]/state
|
|
@@ -34572,7 +34619,7 @@ index 2fc11ef..a309317 100644
|
|
| 34572 |
- Heights (`SIDEBAR_ROW_HEIGHTS`) keep one rhythm: session rows, every header (a project's, the pinned section's, the archive's projects), the empty group's note and the footer's two links are 28px (44px on a phone, a finger's minimum, headers included); only "show more" is a step shorter (26px, 36px). Every control in a row fits it with 2px to spare (⋯ and archive, the rename field and a delete confirmation's buttons 24px, a header's + and ⋯ 22px; 32px on a phone; `SessionTree.test.mjs` checks it), and the drag ghost (22px) stays shorter than a header.
|
| 34573 |
-- 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.
|
| 34574 |
-- Scroll-edge fades (`app/globals.css`): the session list, the files below it, a code block's `<pre>` and a markdown table's scroller fade the edge that has more content past it. A scroll-driven animation (`animation-timeline: scroll(self …)`) sizes each side from the scroll position, so there is no scroll listener; without overflow the timeline is inactive and nothing fades; browsers without scroll-driven animations (Firefox, Safari before 26) show none. The lists keep the classic scrollbar's strip unmasked (`--session-tree-scrollbar`). A mask fades whatever its element paints, borders included, so a markdown table's border sits on `.markdown-table-frame` and the scroller inside it keeps the `.markdown-table-wrap` class the dialog width fit and the e2e measure.
|
| 34575 |
-
+- 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. In the transcript only an answer (text without tool calls) has an actions row, and its Copy shows on hover or focus with the model, usage and time (always on a touch screen); a whitespace-only text block (DeepSeek sends "\n\n" beside each tool call) renders nothing, and a running turn's messages are drawn as steps. Steps are one 4px rhythm, within a message and between messages (set on the messages' wrappers), folded or live; the answer sits 8px under its steps, open or closed. A live row has no hover Archive button (Archive is in its ⋯ menu); the archive view's rows keep Restore one click away. The toolbar's search opens the command bar (`components/SearchCommandBar.tsx`, also ⌘K / Ctrl+K outside a terminal): recent top-level conversations until something is typed, then `/api/sessions/search` matches with their text
|
| 34576 |
- Row keys are unique and stable (`session:<context>:<rootId>`, `group:<key>`, `more:<key>`, `empty:<key>`, and fixed keys for the pinned header, the two spacers (`pinned-spacer`, `footer-spacer`) and the footer); focus tracking and `keepMounted` go by them.
|
| 34577 |
- 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.
|
| 34578 |
- 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 `<body>`). 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.
|
|
@@ -44429,7 +44476,7 @@ index 6b8ee45..074fa4d 100644
|
|
| 44429 |
function countTurnsBefore(entries: SessionEntry[], oldest: SessionEntry): number {
|
| 44430 |
const byId = new Map<string, SessionEntry>();
|
| 44431 |
diff --git a/lib/session-search.test.mjs b/lib/session-search.test.mjs
|
| 44432 |
-
index a7372a7..
|
| 44433 |
--- a/lib/session-search.test.mjs
|
| 44434 |
+++ b/lib/session-search.test.mjs
|
| 44435 |
@@ -30,6 +30,27 @@ test("finds one literal, case-insensitive snippet per session, newest first", as
|
|
@@ -44460,8 +44507,36 @@ index a7372a7..268b96f 100644
|
|
| 44460 |
test("identifies the matching text block across thinking, tools and multiple text blocks", async (t) => {
|
| 44461 |
const session = fixture(t, [message("assistant", [
|
| 44462 |
{ type: "thinking", thinking: "pi-cwd-spark" },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44463 |
diff --git a/lib/session-search.ts b/lib/session-search.ts
|
| 44464 |
-
index aa13d4a..
|
| 44465 |
--- a/lib/session-search.ts
|
| 44466 |
+++ b/lib/session-search.ts
|
| 44467 |
@@ -12,6 +12,8 @@ export interface SessionSearchResult {
|
|
@@ -44473,28 +44548,71 @@ index aa13d4a..29c7b50 100644
|
|
| 44473 |
before: string;
|
| 44474 |
match: string;
|
| 44475 |
after: string;
|
| 44476 |
-
@@ -
|
| 44477 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44478 |
}
|
| 44479 |
if (!found && stream.bytesRead >= MAX_FILE_BYTES) response.truncated = true;
|
| 44480 |
-
+ // Preserve message navigation when both body and title match. The catalogue
|
| 44481 |
-
+ // already carries the displayed name (including session_info names), so a
|
| 44482 |
-
+ // title hit is a fallback only when the transcript has no match.
|
| 44483 |
-
+ if (!found && !signal.aborted && Date.now() < deadline) {
|
| 44484 |
-
+ const title = session.name || session.firstMessage || "";
|
| 44485 |
-
+ const titleHit = matcher.exec(title);
|
| 44486 |
-
+ if (titleHit) {
|
| 44487 |
-
+ response.results.push({
|
| 44488 |
-
+ session,
|
| 44489 |
-
+ blockIndex: 0,
|
| 44490 |
-
+ titleMatch: true,
|
| 44491 |
-
+ before: title.slice(0, titleHit.index),
|
| 44492 |
-
+ match: titleHit[0],
|
| 44493 |
-
+ after: title.slice(titleHit.index + titleHit[0].length),
|
| 44494 |
-
+ });
|
| 44495 |
-
+ }
|
| 44496 |
-
+ }
|
| 44497 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44498 |
return response;
|
| 44499 |
}
|
| 44500 |
diff --git a/lib/shortcuts.ts b/lib/shortcuts.ts
|
|
|
|
| 25595 |
)}
|
| 25596 |
</button>
|
| 25597 |
{report && (
|
| 25598 |
+
diff --git a/components/SearchCommandBar.test.mjs b/components/SearchCommandBar.test.mjs
|
| 25599 |
+
new file mode 100644
|
| 25600 |
+
index 0000000..5b40b0f
|
| 25601 |
+
--- /dev/null
|
| 25602 |
+
+++ b/components/SearchCommandBar.test.mjs
|
| 25603 |
+
@@ -0,0 +1,26 @@
|
| 25604 |
+
+import assert from "node:assert/strict";
|
| 25605 |
+
+import test from "node:test";
|
| 25606 |
+
+import { createJiti } from "jiti";
|
| 25607 |
+
+
|
| 25608 |
+
+const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true });
|
| 25609 |
+
+const React = await jiti.import("react");
|
| 25610 |
+
+const { renderToStaticMarkup } = await jiti.import("react-dom/server");
|
| 25611 |
+
+const { markTitle, repeatsTitle } = await jiti.import("./SearchCommandBar.tsx");
|
| 25612 |
+
+
|
| 25613 |
+
+const html = (title, query) => renderToStaticMarkup(React.createElement(React.Fragment, null, markTitle(title, query)));
|
| 25614 |
+
+
|
| 25615 |
+
+test("a result's title marks the query's first match, literally and in any case", () => {
|
| 25616 |
+
+ assert.equal(html("Building the release notes", " building "), "<mark>Building</mark> the release notes");
|
| 25617 |
+
+ assert.equal(html("Keep building, then building more", "BUILDING"), "Keep <mark>building</mark>, then building more");
|
| 25618 |
+
+ assert.equal(html("Fix a+b (and more)", "a+b ("), "Fix <mark>a+b (</mark>and more)");
|
| 25619 |
+
+ assert.equal(html("yo", "building"), "yo");
|
| 25620 |
+
+ assert.equal(html("Anything", " "), "Anything");
|
| 25621 |
+
+});
|
| 25622 |
+
+
|
| 25623 |
+
+test("a snippet that only repeats the title is not shown again", () => {
|
| 25624 |
+
+ const hit = (before, match, after) => ({ session: {}, blockIndex: 0, before, match, after });
|
| 25625 |
+
+ assert.equal(repeatsTitle(hit("", "Building", " a leaderboard for high scores"), "Building a leaderboard for high scores"), true);
|
| 25626 |
+
+ assert.equal(repeatsTitle(hit("", "Building", " a leaderboard for high scores and a very long tail..."), "Building a leaderboard for high"), true);
|
| 25627 |
+
+ assert.equal(repeatsTitle(hit("Space is ", "building", " on cpu-basic"), "yo"), false);
|
| 25628 |
+
+ assert.equal(repeatsTitle(hit("...as we said, ", "building", " it again"), "Building a leaderboard"), false);
|
| 25629 |
+
+});
|
| 25630 |
diff --git a/components/SearchCommandBar.tsx b/components/SearchCommandBar.tsx
|
| 25631 |
new file mode 100644
|
| 25632 |
+
index 0000000..759983d
|
| 25633 |
--- /dev/null
|
| 25634 |
+++ b/components/SearchCommandBar.tsx
|
| 25635 |
+
@@ -0,0 +1,210 @@
|
| 25636 |
+"use client";
|
| 25637 |
+
|
| 25638 |
+// The conversation search as a command bar: one floating panel centred over
|
|
|
|
| 25644 |
+// replaced the field that used to unfold in the sidebar.
|
| 25645 |
+
|
| 25646 |
+import { Dialog } from "@base-ui/react/dialog";
|
| 25647 |
+
+import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
| 25648 |
+import { useI18n } from "@/hooks/useI18n";
|
| 25649 |
+import { formatShortRelativeTime } from "@/lib/i18n/format";
|
| 25650 |
+import type { SessionInfo } from "@/lib/types";
|
|
|
|
| 25667 |
+ return session.name || session.firstMessage || session.id;
|
| 25668 |
+}
|
| 25669 |
+
|
| 25670 |
+
+/** A snippet that only repeats the title (a conversation named by its first message) shows nothing new. */
|
| 25671 |
+
+export function repeatsTitle(result: SessionSearchResult, title: string): boolean {
|
| 25672 |
+
+ const core = `${result.before}${result.match}${result.after}`.replace(/^\.\.\.|\.\.\.$/g, "").replace(/\s+/g, " ").trim();
|
| 25673 |
+
+ const name = title.replace(/\s+/g, " ").trim();
|
| 25674 |
+
+ return core.length > 0 && (name.startsWith(core) || core.startsWith(name));
|
| 25675 |
+
+}
|
| 25676 |
+
+
|
| 25677 |
+
+/** The title with its first match of the query marked: the same literal, case-insensitive match the search makes. */
|
| 25678 |
+
+export function markTitle(title: string, query: string): ReactNode {
|
| 25679 |
+
+ const needle = query.trim();
|
| 25680 |
+
+ if (!needle) return title;
|
| 25681 |
+
+ const hit = new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").exec(title);
|
| 25682 |
+
+ if (!hit) return title;
|
| 25683 |
+
+ return <>{title.slice(0, hit.index)}<mark>{hit[0]}</mark>{title.slice(hit.index + hit[0].length)}</>;
|
| 25684 |
+
+}
|
| 25685 |
+
+
|
| 25686 |
+/** The recent conversations: top-level ones (no subagents), not archived, newest first. */
|
| 25687 |
+export function recentConversations(sessions: readonly SessionInfo[], archived?: ReadonlySet<string>, limit = RECENT_LIMIT): SessionInfo[] {
|
| 25688 |
+ return sessions
|
|
|
|
| 25819 |
+ >
|
| 25820 |
+ <span className="command-bar-row-line">
|
| 25821 |
+ <span className="command-bar-row-title">
|
| 25822 |
+
+ {/* A match marks the title too when the title has it; matched by its title alone (a rename, a
|
| 25823 |
+
+ generated title), a row shows no message snippet. */}
|
| 25824 |
+
+ {row.kind === "match" ? markTitle(titleOf(session), search) : titleOf(session)}
|
|
|
|
| 25825 |
+ </span>
|
| 25826 |
+ {archivedSessionIds?.has(session.id) && <span className="command-bar-row-tag">{t("sidebar.archived")}</span>}
|
| 25827 |
+ <span className="command-bar-row-meta">
|
| 25828 |
+ {projectName(session)} · {formatShortRelativeTime(new Date(session.modified), locale)}
|
| 25829 |
+ </span>
|
| 25830 |
+ </span>
|
| 25831 |
+
+ {row.kind === "match" && !row.result.titleMatch && !repeatsTitle(row.result, titleOf(session)) && (
|
| 25832 |
+ <span className="command-bar-row-snippet">
|
| 25833 |
+ {row.result.before}<mark>{row.result.match}</mark>{row.result.after}
|
| 25834 |
+ </span>
|
|
|
|
| 34561 |
+- A models.json model on `anthropic-messages` (its own `api`, else its provider's) gets **Claude adaptive thinking** under Advanced settings › Compatibility, which writes pi's `compat.forceAdaptiveThinking` (built-in Claude 4.6+ models set it in generated metadata; pi never infers it from a custom model's id). Without it pi sends `thinking.type: "enabled"` with a token budget (`xhigh` and `max` clamp to high's 16384). Claude Opus 5.5 through a gateway answered that with an empty thinking block and no bytes at all until the answer began, so minutes of silent thinking ended in `terminated`; adaptive thinking streams the summary. Switching it off removes the model's key, or writes `false` when the provider's `compat` turns it on.
|
| 34562 |
- Fetch models (`lib/model-discovery.ts`) copies only capability fields whose meaning does not vary between gateways into the added entries: `contextWindow`, `maxTokens`, `reasoning: true`, and a `thinkingLevelMap` from an advertised effort list naming pi's own levels (others `null`, `off` never touched). Ambiguous fields (`max_tokens`, `default_effort`) and junk values are dropped (#856).
|
| 34563 |
diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md
|
| 34564 |
+
index 2fc11ef..297f045 100644
|
| 34565 |
--- a/docs/agents/sessions.md
|
| 34566 |
+++ b/docs/agents/sessions.md
|
| 34567 |
@@ -42,7 +42,7 @@ On mount `useAgentSession` loads the history, then `GET /api/sessions/[id]/state
|
|
|
|
| 34619 |
- Heights (`SIDEBAR_ROW_HEIGHTS`) keep one rhythm: session rows, every header (a project's, the pinned section's, the archive's projects), the empty group's note and the footer's two links are 28px (44px on a phone, a finger's minimum, headers included); only "show more" is a step shorter (26px, 36px). Every control in a row fits it with 2px to spare (⋯ and archive, the rename field and a delete confirmation's buttons 24px, a header's + and ⋯ 22px; 32px on a phone; `SessionTree.test.mjs` checks it), and the drag ghost (22px) stays shorter than a header.
|
| 34620 |
-- 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.
|
| 34621 |
-- Scroll-edge fades (`app/globals.css`): the session list, the files below it, a code block's `<pre>` and a markdown table's scroller fade the edge that has more content past it. A scroll-driven animation (`animation-timeline: scroll(self …)`) sizes each side from the scroll position, so there is no scroll listener; without overflow the timeline is inactive and nothing fades; browsers without scroll-driven animations (Firefox, Safari before 26) show none. The lists keep the classic scrollbar's strip unmasked (`--session-tree-scrollbar`). A mask fades whatever its element paints, borders included, so a markdown table's border sits on `.markdown-table-frame` and the scroller inside it keeps the `.markdown-table-wrap` class the dialog width fit and the e2e measure.
|
| 34622 |
+
+- 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. In the transcript only an answer (text without tool calls) has an actions row, and its Copy shows on hover or focus with the model, usage and time (always on a touch screen); a whitespace-only text block (DeepSeek sends "\n\n" beside each tool call) renders nothing, and a running turn's messages are drawn as steps. Steps are one 4px rhythm, within a message and between messages (set on the messages' wrappers), folded or live; the answer sits 8px under its steps, open or closed. A live row has no hover Archive button (Archive is in its ⋯ menu); the archive view's rows keep Restore one click away. The toolbar's search opens the command bar (`components/SearchCommandBar.tsx`, also ⌘K / Ctrl+K outside a terminal): recent top-level conversations until something is typed, then `/api/sessions/search` matches with their text; arrows move, Enter opens, Esc closes. Titles (the name, a rename or a generated title, else the first message) are checked for every conversation before any file is read, so the transcript scan's budget (3 s, 500 files, 30 results) never hides a title match, and conversations whose title matches come first. One matching only by its title is a `titleMatch` (no snippet); one matching in a message too keeps the message hit, so Enter lands on it. The command bar marks the query in every result's title, and drops a snippet that only repeats the title. 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.
|
| 34623 |
- Row keys are unique and stable (`session:<context>:<rootId>`, `group:<key>`, `more:<key>`, `empty:<key>`, and fixed keys for the pinned header, the two spacers (`pinned-spacer`, `footer-spacer`) and the footer); focus tracking and `keepMounted` go by them.
|
| 34624 |
- 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.
|
| 34625 |
- 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 `<body>`). 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.
|
|
|
|
| 44476 |
function countTurnsBefore(entries: SessionEntry[], oldest: SessionEntry): number {
|
| 44477 |
const byId = new Map<string, SessionEntry>();
|
| 44478 |
diff --git a/lib/session-search.test.mjs b/lib/session-search.test.mjs
|
| 44479 |
+
index a7372a7..b8833fb 100644
|
| 44480 |
--- a/lib/session-search.test.mjs
|
| 44481 |
+++ b/lib/session-search.test.mjs
|
| 44482 |
@@ -30,6 +30,27 @@ test("finds one literal, case-insensitive snippet per session, newest first", as
|
|
|
|
| 44507 |
test("identifies the matching text block across thinking, tools and multiple text blocks", async (t) => {
|
| 44508 |
const session = fixture(t, [message("assistant", [
|
| 44509 |
{ type: "thinking", thinking: "pi-cwd-spark" },
|
| 44510 |
+
@@ -110,3 +131,27 @@ test("reports file and result caps even when no conversations match", async (t)
|
| 44511 |
+
assert.equal(capped.truncated, true);
|
| 44512 |
+
assert.equal((await searchSessionContents(sessions.slice(0, 30), "needle")).truncated, false);
|
| 44513 |
+
});
|
| 44514 |
+
+
|
| 44515 |
+
+test("a title-only match is found even past the transcripts the scan reads", async (t) => {
|
| 44516 |
+
+ // 501 newer conversations fill the scan's file budget; the oldest is matched by its title alone.
|
| 44517 |
+
+ const filler = Array.from({ length: 501 }, (_, i) => fixture(t, [message("user", "nothing here")], {
|
| 44518 |
+
+ id: `filler-${i}`, modified: `2026-03-${String(1 + (i % 28)).padStart(2, "0")}`, firstMessage: "nothing here",
|
| 44519 |
+
+ }));
|
| 44520 |
+
+ const old = fixture(t, [message("user", "unrelated body")], { id: "old", name: "Building the release notes" });
|
| 44521 |
+
+ const result = await searchSessionContents([...filler, old], "building");
|
| 44522 |
+
+ assert.deepEqual(result.results.map(({ session }) => session.id), ["old"]);
|
| 44523 |
+
+ assert.equal(result.results[0].titleMatch, true);
|
| 44524 |
+
+ assert.equal(result.truncated, true, "the transcripts past the budget were not read");
|
| 44525 |
+
+});
|
| 44526 |
+
+
|
| 44527 |
+
+test("conversations whose title matches come first; one that also matches in a message keeps the message", async (t) => {
|
| 44528 |
+
+ const bodyOnly = fixture(t, [message("assistant", [{ type: "text", text: "Space is building on cpu-basic" }])], { id: "body", modified: "2026-03-01", name: "yo" });
|
| 44529 |
+
+ const titleOnly = fixture(t, [message("user", "unrelated")], { id: "title", modified: "2026-01-01", name: "Building a Space" });
|
| 44530 |
+
+ const both = fixture(t, [{ ...message("user", "keep building it"), id: "hit" }], { id: "both", modified: "2026-02-01", name: "Building the docs" });
|
| 44531 |
+
+ const result = await searchSessionContents([bodyOnly, titleOnly, both], "building");
|
| 44532 |
+
+ assert.deepEqual(result.results.map(({ session }) => session.id), ["both", "title", "body"]);
|
| 44533 |
+
+ assert.equal(result.results[0].entryId, "hit", "opening it lands on the message");
|
| 44534 |
+
+ assert.equal(result.results[0].titleMatch, undefined);
|
| 44535 |
+
+ assert.equal(result.results[1].titleMatch, true);
|
| 44536 |
+
+ assert.equal(result.results[2].match, "building");
|
| 44537 |
+
+});
|
| 44538 |
diff --git a/lib/session-search.ts b/lib/session-search.ts
|
| 44539 |
+
index aa13d4a..9eca619 100644
|
| 44540 |
--- a/lib/session-search.ts
|
| 44541 |
+++ b/lib/session-search.ts
|
| 44542 |
@@ -12,6 +12,8 @@ export interface SessionSearchResult {
|
|
|
|
| 44548 |
before: string;
|
| 44549 |
match: string;
|
| 44550 |
after: string;
|
| 44551 |
+
@@ -22,6 +24,11 @@ export interface SessionSearchResponse {
|
| 44552 |
+
truncated: boolean;
|
| 44553 |
+
}
|
| 44554 |
+
|
| 44555 |
+
+/** The title a conversation is listed under: its name (a rename, a generated title), else its first message. */
|
| 44556 |
+
+function displayedTitle(session: SessionInfo): string {
|
| 44557 |
+
+ return session.name || session.firstMessage || "";
|
| 44558 |
+
+}
|
| 44559 |
+
+
|
| 44560 |
+
// ponytail: scan recent files without an index; add indexing if measured latency warrants it.
|
| 44561 |
+
export async function searchSessionContents(
|
| 44562 |
+
sessions: readonly SessionInfo[],
|
| 44563 |
+
@@ -41,8 +48,26 @@ export async function searchSessionContents(
|
| 44564 |
+
.filter((session) => !session.transient && session.path)
|
| 44565 |
+
.sort((a, b) => b.modified.localeCompare(a.modified));
|
| 44566 |
+
|
| 44567 |
+
+ // Titles are already in the catalogue, so every candidate's is checked, whatever
|
| 44568 |
+
+ // the transcript scan's budget leaves unread, and those conversations come first.
|
| 44569 |
+
+ const titleResults = new Map<string, SessionSearchResult>();
|
| 44570 |
+
+ for (const session of candidates) {
|
| 44571 |
+
+ const title = displayedTitle(session);
|
| 44572 |
+
+ const titleHit = matcher.exec(title);
|
| 44573 |
+
+ if (!titleHit) continue;
|
| 44574 |
+
+ titleResults.set(session.id, {
|
| 44575 |
+
+ session,
|
| 44576 |
+
+ blockIndex: 0,
|
| 44577 |
+
+ titleMatch: true,
|
| 44578 |
+
+ before: title.slice(0, titleHit.index),
|
| 44579 |
+
+ match: titleHit[0],
|
| 44580 |
+
+ after: title.slice(titleHit.index + titleHit[0].length),
|
| 44581 |
+
+ });
|
| 44582 |
+
+ }
|
| 44583 |
+
+ const messageResults = new Map<string, SessionSearchResult>();
|
| 44584 |
+
+
|
| 44585 |
+
for (const [index, session] of candidates.entries()) {
|
| 44586 |
+
- if (index >= MAX_FILES || response.results.length >= MAX_RESULTS || signal.aborted || Date.now() >= deadline) {
|
| 44587 |
+
+ if (index >= MAX_FILES || messageResults.size + titleResults.size >= MAX_RESULTS || signal.aborted || Date.now() >= deadline) {
|
| 44588 |
+
response.truncated = true;
|
| 44589 |
+
break;
|
| 44590 |
+
}
|
| 44591 |
+
@@ -76,7 +101,7 @@ export async function searchSessionContents(
|
| 44592 |
+
blockOffset = blockEnd + 1;
|
| 44593 |
+
return start <= blockEnd;
|
| 44594 |
+
});
|
| 44595 |
+
- response.results.push({
|
| 44596 |
+
+ messageResults.set(session.id, {
|
| 44597 |
+
session,
|
| 44598 |
+
...(typeof entry.id === "string" ? { entryId: entry.id } : {}),
|
| 44599 |
+
blockIndex: matchedBlock!.blockIndex,
|
| 44600 |
+
@@ -96,5 +121,17 @@ export async function searchSessionContents(
|
| 44601 |
}
|
| 44602 |
if (!found && stream.bytesRead >= MAX_FILE_BYTES) response.truncated = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44603 |
}
|
| 44604 |
+
+ // A title hit that also matches in a message keeps the message, so opening it lands
|
| 44605 |
+
+ // there (the command bar marks the title too). Title hits first, both newest first.
|
| 44606 |
+
+ for (const [id, title] of titleResults) {
|
| 44607 |
+
+ const inMessage = messageResults.get(id);
|
| 44608 |
+
+ response.results.push(inMessage ?? title);
|
| 44609 |
+
+ messageResults.delete(id);
|
| 44610 |
+
+ }
|
| 44611 |
+
+ response.results.push(...messageResults.values());
|
| 44612 |
+
+ if (response.results.length > MAX_RESULTS) {
|
| 44613 |
+
+ response.results.length = MAX_RESULTS;
|
| 44614 |
+
+ response.truncated = true;
|
| 44615 |
+
+ }
|
| 44616 |
return response;
|
| 44617 |
}
|
| 44618 |
diff --git a/lib/shortcuts.ts b/lib/shortcuts.ts
|