Skip to content
Closed
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
1 change: 0 additions & 1 deletion cmd/agentsview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,4 +664,3 @@ func startUnwatchedPoll(engine *sync.Engine) {
engine.SyncAll(context.Background(), nil)
}
}

76 changes: 67 additions & 9 deletions cmd/agentsview/pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func runPGPush(args []string) {
fs := flag.NewFlagSet("pg push", flag.ExitOnError)
full := fs.Bool("full", false,
"Force full local resync and PG push")
clearBackfill := fs.String("clear-backfill", "",
"Clear backfill_pending flag for a retired machine "+
"that can no longer push")
if err := fs.Parse(args); err != nil {
log.Fatalf("parsing flags: %v", err)
}
Expand All @@ -55,6 +58,41 @@ func runPGPush(args []string) {
}
setupLogFile(appCfg.DataDir)

pgCfg, err := appCfg.ResolvePG()
if err != nil {
fatal("pg push: %v", err)
}
if pgCfg.URL == "" {
fatal("pg push: url not configured")
}

// --clear-backfill is a PG-only operation: skip local
// sync/SQLite setup entirely.
if *clearBackfill != "" {
pgDB, oErr := postgres.Open(
pgCfg.URL, pgCfg.Schema, pgCfg.AllowInsecure,
)
if oErr != nil {
fatal("pg push: %v", oErr)
}
defer pgDB.Close()
ctx, stop := signal.NotifyContext(
context.Background(), os.Interrupt,
)
defer stop()
if err := postgres.ClearBackfillPending(
ctx, pgDB, *clearBackfill,
); err != nil {
fatal("pg push: clearing backfill for %s: %v",
*clearBackfill, err)
}
fmt.Printf(
"Cleared backfill_pending for machine %q\n",
*clearBackfill,
)
return
}

database, err := db.Open(appCfg.DBPath)
if err != nil {
fatal("opening database: %v", err)
Expand All @@ -78,14 +116,6 @@ func runPGPush(args []string) {
didResync := runLocalSync(appCfg, database, *full)
forceFull := *full || didResync

pgCfg, err := appCfg.ResolvePG()
if err != nil {
fatal("pg push: %v", err)
}
if pgCfg.URL == "" {
fatal("pg push: url not configured")
}

ps, err := postgres.New(
pgCfg.URL, pgCfg.Schema, database,
pgCfg.MachineName, pgCfg.AllowInsecure,
Expand All @@ -100,9 +130,34 @@ func runPGPush(args []string) {
)
defer stop()

// Read the schema version before EnsureSchema upgrades it so
// we can detect a v1→v2 transition and force a full push.
// Errors (e.g. pre-v1 schema where sync_metadata doesn't
// exist yet) are treated as version 0.
priorVersion, _ := postgres.GetSchemaVersion(ctx, ps.DB())
if err := ps.EnsureSchema(ctx); err != nil {
fatal("pg push schema: %v", err)
}
if !forceFull && priorVersion < postgres.SchemaVersion {
forceFull = true
log.Printf(
"pg push: schema upgrade v%d→v%d detected; "+
"forcing full push to backfill is_system",
priorVersion, postgres.SchemaVersion,
)
}
// Also force full if a previous push was interrupted
// mid-backfill (backfill_pending flag is still set).
if !forceFull && postgres.IsBackfillPendingForMachine(
ctx, ps.DB(), pgCfg.MachineName,
) {
forceFull = true
log.Printf(
"pg push: is_system backfill incomplete " +
"(previous push interrupted); " +
"forcing full push to resume",
)
}
result, err := ps.Push(ctx, forceFull)
if err != nil {
fatal("pg push: %v", err)
Expand Down Expand Up @@ -216,7 +271,10 @@ func runPGServe(args []string) {
if err := postgres.CheckSchemaCompat(
ctx, store.DB(),
); err != nil {
fatal("pg serve: schema incompatible: %v", err)
fatal("pg serve: schema incompatible: %v\n"+
"Run 'agentsview pg push --full' from a machine "+
"with write access to migrate the schema and "+
"backfill any newly added columns.", err)
}

appCfg.Host = *host
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,16 @@ describe("query serialization", () => {
expect(lastUrl()).toBe("/api/v1/search?q=hello");
});

it("includes sort param when provided", async () => {
await search("hello", { sort: "recency" });
expect(lastUrl()).toBe("/api/v1/search?q=hello&sort=recency");
});

it("omits sort param when not provided", async () => {
await search("hello");
expect(lastUrl()).toBe("/api/v1/search?q=hello");
});

it("rejects empty query string", () => {
expect(() => search("")).toThrow(
"search query must not be empty",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export function search(
project?: string;
limit?: number;
cursor?: number;
sort?: "relevance" | "recency";
} = {},
init?: RequestInit,
): Promise<SearchResponse> {
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/lib/api/types/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface Message {
context_tokens: number;
output_tokens: number;
tool_calls?: ToolCall[];
is_system: boolean;
}

/** Matches Go MinimapEntry struct */
Expand All @@ -81,9 +82,10 @@ export type MinimapEntry = Pick<
export interface SearchResult {
session_id: string;
project: string;
agent: string;
name: string;
ordinal: number;
role: string;
timestamp: string;
session_ended_at: string;
snippet: string;
rank: number;
}
Expand Down
142 changes: 113 additions & 29 deletions frontend/src/lib/components/command-palette/CommandPalette.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { tick } from "svelte";
import { tick, onDestroy } from "svelte";
import { ui } from "../../stores/ui.svelte.js";
import { sessions } from "../../stores/sessions.svelte.js";
import { searchStore } from "../../stores/search.svelte.js";
Expand All @@ -10,12 +10,23 @@
sanitizeSnippet,
} from "../../utils/format.js";
import { agentColor } from "../../utils/agents.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { stripIdPrefix } from "../../utils/resume.js";
import type { Session, SearchResult } from "../../api/types.js";

let inputRef: HTMLInputElement | undefined = $state(undefined);
let selectedIndex: number = $state(0);
let inputValue: string = $state("");

// Clear state and reset sort whenever the palette is unmounted, regardless
// of close path (Escape key, overlay click, Cmd+K toggle, or any other
// mechanism). This ensures stale results and in-flight requests are always
// cancelled even when the caller bypasses close().
onDestroy(() => {
searchStore.clear();
searchStore.resetSort();
});

// Filtered recent sessions (client-side filter)
let recentSessions = $derived.by(() => {
if (inputValue.length > 0 && inputValue.length < 3) {
Expand Down Expand Up @@ -92,13 +103,18 @@

function selectSearchResult(r: SearchResult) {
sessions.selectSession(r.session_id);
ui.scrollToOrdinal(r.ordinal, r.session_id);
if (r.ordinal !== -1) {
ui.scrollToOrdinal(r.ordinal, r.session_id);
} else {
// Name-only match: clear any stale selection/scroll state so the
// previously highlighted ordinal is not left active.
ui.clearScrollState();
}
close();
}

function close() {
inputValue = "";
searchStore.clear();
ui.activeModal = null;
}

Expand Down Expand Up @@ -149,6 +165,20 @@

<div class="palette-results">
{#if showSearchResults}
<div class="palette-sort">
<button
class="sort-btn"
class:active={searchStore.sort === "relevance"}
onmousedown={(e: MouseEvent) => e.preventDefault()}
onclick={() => { searchStore.setSort("relevance"); selectedIndex = 0; }}
>Relevance</button>
<button
class="sort-btn"
class:active={searchStore.sort === "recency"}
onmousedown={(e: MouseEvent) => e.preventDefault()}
onclick={() => { searchStore.setSort("recency"); selectedIndex = 0; }}
>Recency</button>
</div>
{#if searchStore.isSearching}
<div class="palette-empty">Searching...</div>
{:else if searchStore.results.length === 0}
Expand All @@ -161,15 +191,33 @@
onclick={() => selectSearchResult(result)}
onmouseenter={() => (selectedIndex = i)}
>
<span class="item-role" class:user={result.role === "user"}>
{result.role === "user" ? "U" : "A"}
</span>
<span class="item-text">
{@html sanitizeSnippet(result.snippet)}
<span
class="item-dot"
style:background={agentColor(result.agent)}
></span>
<span class="item-body">
{#if result.name}
<span class="item-name">{truncate(result.name, 60)}</span>
{/if}
{#if result.snippet && result.snippet.replace(/<\/?mark>/g, '') !== result.name}
<span class="item-snippet">
{@html sanitizeSnippet(result.snippet)}
</span>
{/if}
</span>
<span class="item-meta">
{truncate(result.project, 20)}
{truncate(result.project, 20)}{result.session_ended_at ? ' · ' + formatRelativeTime(result.session_ended_at) : ''}
</span>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="item-id"
title="Copy session ID"
onclick={(e) => {
e.stopPropagation();
copyToClipboard(result.session_id);
}}
>{stripIdPrefix(result.session_id, result.agent).slice(0, 8)}</span>
</button>
{/each}
{/if}
Expand All @@ -183,10 +231,10 @@
onmouseenter={() => (selectedIndex = i)}
>
<span class="item-dot" style:background={agentColor(session.agent)}></span>
<span class="item-text">
{session.first_message
<span class="item-body">
<span class="item-name">{session.first_message
? truncate(session.first_message, 60)
: session.project}
: session.project}</span>
</span>
<span class="item-meta">
{formatRelativeTime(session.ended_at ?? session.started_at)}
Expand Down Expand Up @@ -296,31 +344,28 @@
flex-shrink: 0;
}

.item-role {
width: 18px;
height: 18px;
.item-body {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
background: var(--assistant-bg);
color: var(--accent-purple);
flex-direction: column;
gap: 1px;
}

.item-role.user {
background: var(--user-bg);
color: var(--accent-blue);
.item-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--text-primary);
}

.item-text {
flex: 1;
min-width: 0;
.item-snippet {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--text-muted);
}

.item-meta {
Expand All @@ -336,4 +381,43 @@
color: var(--text-muted);
font-size: 13px;
}

.palette-sort {
display: flex;
gap: 4px;
padding: 6px 14px 2px;
}

.sort-btn {
padding: 2px 8px;
font-size: 11px;
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
font-family: var(--font-sans);
}

.sort-btn.active {
background: var(--bg-surface-hover);
color: var(--text-primary);
border-color: var(--accent-purple);
}

.item-id {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
padding: 1px 3px;
border-radius: var(--radius-sm);
}

.item-id:hover {
background: var(--bg-inset);
color: var(--text-primary);
}
</style>
Loading
Loading