Skip to content

Commit d6de2dc

Browse files
wesmTom Maloneyclaude
authored
feat: session-grouped search with sort toggle and name matching (#210)
Supersedes #200. ## Summary - **Session-grouped results**: FTS5 search returns one result per session. The best-ranked matching message provides the snippet and scroll target. - **Relevance/Recency sort toggle**: two-button control above results. Sort validated server-side before ORDER BY interpolation. Toggle visible during loading and zero-results states. - **Session name search**: matches `display_name` and `first_message` via a UNION with LIKE/ILIKE. Name-only matches use `ordinal = -1`; frontend navigates without scrolling. - **is_system flag**: parsers tag system-injected user messages; search excludes them via column check and prefix fallback. - **Case-sensitive prefix matching**: `SystemPrefixSQL` uses `substr()` instead of `LIKE` for consistent behavior across SQLite (case-insensitive LIKE) and PostgreSQL. - **PostgreSQL parity**: `pg serve` path updated with `DISTINCT ON` grouping, `is_system` column, ILIKE name branch. - **FTS5 dedup fix**: outer JOIN includes MATCH clause to prevent segment-level row duplication. - **Stable timestamp ordering**: `julianday()` in SQLite ORDER BY avoids lexicographic misorderings from variable fractional-second precision. ## Test plan - [ ] `make test` — Go unit tests pass - [ ] `make test-postgres` — PG integration tests pass - [ ] `cd frontend && npx vitest run` — frontend tests pass - [ ] Manual: Cmd+K search, verify session-grouped results with name/snippet/meta - [ ] Manual: Relevance/Recency toggle reorders results - [ ] Manual: search term only in session name, verify ordinal=-1 behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tom Maloney <tom@supermaloney.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e512967 commit d6de2dc

33 files changed

+2723
-191
lines changed

cmd/agentsview/main.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,4 +664,3 @@ func startUnwatchedPoll(engine *sync.Engine) {
664664
engine.SyncAll(context.Background(), nil)
665665
}
666666
}
667-

cmd/agentsview/pg.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ func runPGPush(args []string) {
5555
}
5656
setupLogFile(appCfg.DataDir)
5757

58+
pgCfg, err := appCfg.ResolvePG()
59+
if err != nil {
60+
fatal("pg push: %v", err)
61+
}
62+
if pgCfg.URL == "" {
63+
fatal("pg push: url not configured")
64+
}
65+
5866
database, err := db.Open(appCfg.DBPath)
5967
if err != nil {
6068
fatal("opening database: %v", err)
@@ -78,14 +86,6 @@ func runPGPush(args []string) {
7886
didResync := runLocalSync(appCfg, database, *full)
7987
forceFull := *full || didResync
8088

81-
pgCfg, err := appCfg.ResolvePG()
82-
if err != nil {
83-
fatal("pg push: %v", err)
84-
}
85-
if pgCfg.URL == "" {
86-
fatal("pg push: url not configured")
87-
}
88-
8989
ps, err := postgres.New(
9090
pgCfg.URL, pgCfg.Schema, database,
9191
pgCfg.MachineName, pgCfg.AllowInsecure,
@@ -216,7 +216,9 @@ func runPGServe(args []string) {
216216
if err := postgres.CheckSchemaCompat(
217217
ctx, store.DB(),
218218
); err != nil {
219-
fatal("pg serve: schema incompatible: %v", err)
219+
fatal("pg serve: schema incompatible: %v\n"+
220+
"Drop and recreate the PG schema, then run "+
221+
"'agentsview pg push --full' to repopulate.", err)
220222
}
221223

222224
appCfg.Host = *host

frontend/src/lib/api/client.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,16 @@ describe("query serialization", () => {
591591
expect(lastUrl()).toBe("/api/v1/search?q=hello");
592592
});
593593

594+
it("includes sort param when provided", async () => {
595+
await search("hello", { sort: "recency" });
596+
expect(lastUrl()).toBe("/api/v1/search?q=hello&sort=recency");
597+
});
598+
599+
it("omits sort param when not provided", async () => {
600+
await search("hello");
601+
expect(lastUrl()).toBe("/api/v1/search?q=hello");
602+
});
603+
594604
it("rejects empty query string", () => {
595605
expect(() => search("")).toThrow(
596606
"search query must not be empty",

frontend/src/lib/api/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export function search(
212212
project?: string;
213213
limit?: number;
214214
cursor?: number;
215+
sort?: "relevance" | "recency";
215216
} = {},
216217
init?: RequestInit,
217218
): Promise<SearchResponse> {

frontend/src/lib/api/types/core.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export interface Message {
6969
context_tokens: number;
7070
output_tokens: number;
7171
tool_calls?: ToolCall[];
72+
is_system: boolean;
7273
}
7374

7475
/** Matches Go MinimapEntry struct */
@@ -81,9 +82,10 @@ export type MinimapEntry = Pick<
8182
export interface SearchResult {
8283
session_id: string;
8384
project: string;
85+
agent: string;
86+
name: string;
8487
ordinal: number;
85-
role: string;
86-
timestamp: string;
88+
session_ended_at: string;
8789
snippet: string;
8890
rank: number;
8991
}

frontend/src/lib/components/command-palette/CommandPalette.svelte

Lines changed: 113 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { tick } from "svelte";
2+
import { tick, onDestroy } from "svelte";
33
import { ui } from "../../stores/ui.svelte.js";
44
import { sessions } from "../../stores/sessions.svelte.js";
55
import { searchStore } from "../../stores/search.svelte.js";
@@ -10,12 +10,23 @@
1010
sanitizeSnippet,
1111
} from "../../utils/format.js";
1212
import { agentColor } from "../../utils/agents.js";
13+
import { copyToClipboard } from "../../utils/clipboard.js";
14+
import { stripIdPrefix } from "../../utils/resume.js";
1315
import type { Session, SearchResult } from "../../api/types.js";
1416
1517
let inputRef: HTMLInputElement | undefined = $state(undefined);
1618
let selectedIndex: number = $state(0);
1719
let inputValue: string = $state("");
1820
21+
// Clear state and reset sort whenever the palette is unmounted, regardless
22+
// of close path (Escape key, overlay click, Cmd+K toggle, or any other
23+
// mechanism). This ensures stale results and in-flight requests are always
24+
// cancelled even when the caller bypasses close().
25+
onDestroy(() => {
26+
searchStore.clear();
27+
searchStore.resetSort();
28+
});
29+
1930
// Filtered recent sessions (client-side filter)
2031
let recentSessions = $derived.by(() => {
2132
if (inputValue.length > 0 && inputValue.length < 3) {
@@ -92,13 +103,18 @@
92103
93104
function selectSearchResult(r: SearchResult) {
94105
sessions.selectSession(r.session_id);
95-
ui.scrollToOrdinal(r.ordinal, r.session_id);
106+
if (r.ordinal !== -1) {
107+
ui.scrollToOrdinal(r.ordinal, r.session_id);
108+
} else {
109+
// Name-only match: clear any stale selection/scroll state so the
110+
// previously highlighted ordinal is not left active.
111+
ui.clearScrollState();
112+
}
96113
close();
97114
}
98115
99116
function close() {
100117
inputValue = "";
101-
searchStore.clear();
102118
ui.activeModal = null;
103119
}
104120
@@ -149,6 +165,20 @@
149165

150166
<div class="palette-results">
151167
{#if showSearchResults}
168+
<div class="palette-sort">
169+
<button
170+
class="sort-btn"
171+
class:active={searchStore.sort === "relevance"}
172+
onmousedown={(e: MouseEvent) => e.preventDefault()}
173+
onclick={() => { searchStore.setSort("relevance"); selectedIndex = 0; }}
174+
>Relevance</button>
175+
<button
176+
class="sort-btn"
177+
class:active={searchStore.sort === "recency"}
178+
onmousedown={(e: MouseEvent) => e.preventDefault()}
179+
onclick={() => { searchStore.setSort("recency"); selectedIndex = 0; }}
180+
>Recency</button>
181+
</div>
152182
{#if searchStore.isSearching}
153183
<div class="palette-empty">Searching...</div>
154184
{:else if searchStore.results.length === 0}
@@ -161,15 +191,33 @@
161191
onclick={() => selectSearchResult(result)}
162192
onmouseenter={() => (selectedIndex = i)}
163193
>
164-
<span class="item-role" class:user={result.role === "user"}>
165-
{result.role === "user" ? "U" : "A"}
166-
</span>
167-
<span class="item-text">
168-
{@html sanitizeSnippet(result.snippet)}
194+
<span
195+
class="item-dot"
196+
style:background={agentColor(result.agent)}
197+
></span>
198+
<span class="item-body">
199+
{#if result.name}
200+
<span class="item-name">{truncate(result.name, 60)}</span>
201+
{/if}
202+
{#if result.snippet && result.snippet.replace(/<\/?mark>/g, '') !== result.name}
203+
<span class="item-snippet">
204+
{@html sanitizeSnippet(result.snippet)}
205+
</span>
206+
{/if}
169207
</span>
170208
<span class="item-meta">
171-
{truncate(result.project, 20)}
209+
{truncate(result.project, 20)}{result.session_ended_at ? ' · ' + formatRelativeTime(result.session_ended_at) : ''}
172210
</span>
211+
<!-- svelte-ignore a11y_click_events_have_key_events -->
212+
<!-- svelte-ignore a11y_no_static_element_interactions -->
213+
<span
214+
class="item-id"
215+
title="Copy session ID"
216+
onclick={(e) => {
217+
e.stopPropagation();
218+
copyToClipboard(result.session_id);
219+
}}
220+
>{stripIdPrefix(result.session_id, result.agent).slice(0, 8)}</span>
173221
</button>
174222
{/each}
175223
{/if}
@@ -183,10 +231,10 @@
183231
onmouseenter={() => (selectedIndex = i)}
184232
>
185233
<span class="item-dot" style:background={agentColor(session.agent)}></span>
186-
<span class="item-text">
187-
{session.first_message
234+
<span class="item-body">
235+
<span class="item-name">{session.first_message
188236
? truncate(session.first_message, 60)
189-
: session.project}
237+
: session.project}</span>
190238
</span>
191239
<span class="item-meta">
192240
{formatRelativeTime(session.ended_at ?? session.started_at)}
@@ -296,31 +344,28 @@
296344
flex-shrink: 0;
297345
}
298346
299-
.item-role {
300-
width: 18px;
301-
height: 18px;
347+
.item-body {
348+
flex: 1;
349+
min-width: 0;
302350
display: flex;
303-
align-items: center;
304-
justify-content: center;
305-
border-radius: var(--radius-sm);
306-
font-size: 10px;
307-
font-weight: 700;
308-
flex-shrink: 0;
309-
background: var(--assistant-bg);
310-
color: var(--accent-purple);
351+
flex-direction: column;
352+
gap: 1px;
311353
}
312354
313-
.item-role.user {
314-
background: var(--user-bg);
315-
color: var(--accent-blue);
355+
.item-name {
356+
white-space: nowrap;
357+
overflow: hidden;
358+
text-overflow: ellipsis;
359+
font-size: 13px;
360+
color: var(--text-primary);
316361
}
317362
318-
.item-text {
319-
flex: 1;
320-
min-width: 0;
363+
.item-snippet {
321364
white-space: nowrap;
322365
overflow: hidden;
323366
text-overflow: ellipsis;
367+
font-size: 11px;
368+
color: var(--text-muted);
324369
}
325370
326371
.item-meta {
@@ -336,4 +381,43 @@
336381
color: var(--text-muted);
337382
font-size: 13px;
338383
}
384+
385+
.palette-sort {
386+
display: flex;
387+
gap: 4px;
388+
padding: 6px 14px 2px;
389+
}
390+
391+
.sort-btn {
392+
padding: 2px 8px;
393+
font-size: 11px;
394+
border: 1px solid var(--border-default);
395+
border-radius: var(--radius-sm);
396+
background: none;
397+
color: var(--text-muted);
398+
cursor: pointer;
399+
font-family: var(--font-sans);
400+
}
401+
402+
.sort-btn.active {
403+
background: var(--bg-surface-hover);
404+
color: var(--text-primary);
405+
border-color: var(--accent-purple);
406+
}
407+
408+
.item-id {
409+
font-family: var(--font-mono);
410+
font-size: 10px;
411+
color: var(--text-muted);
412+
white-space: nowrap;
413+
flex-shrink: 0;
414+
cursor: pointer;
415+
padding: 1px 3px;
416+
border-radius: var(--radius-sm);
417+
}
418+
419+
.item-id:hover {
420+
background: var(--bg-inset);
421+
color: var(--text-primary);
422+
}
339423
</style>

0 commit comments

Comments
 (0)