<script lang="ts">
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";
import { workbench } from "../../stores/workbench.svelte.js";
import { messages } from "../../stores/messages.svelte.js";
import {
formatRelativeTime,
truncate,
sanitizeSnippet,
} from "../../utils/format.js";
import { agentColor } from "../../utils/agents.js";
import { placeLabel } from "../../utils/places.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { stripIdPrefix } from "../../utils/resume.js";
import type { Session, SearchHit } from "../../api/types.js";
import AppIcon from "../layout/AppIcon.svelte";
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), from the ones the session
// list's scope shows — this worktree's, unless it was widened.
let recentSessions = $derived.by(() => {
if (inputValue.length > 0 && inputValue.length < 3) {
const q = inputValue.toLowerCase();
return sessions.visible
.filter(
(s) =>
placeLabel(s).toLowerCase().includes(q) ||
(s.first_message?.toLowerCase().includes(q) ?? false),
)
.slice(0, 10);
}
if (!inputValue) {
return sessions.visible.slice(0, 10);
}
return [];
});
// Combined results: search results when query >= 3 chars, else recent
let showSearchResults = $derived(inputValue.length >= 3);
let totalItems = $derived(
showSearchResults
? searchStore.results.length
: recentSessions.length,
);
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
inputValue = target.value;
selectedIndex = 0;
if (inputValue.length >= 3) {
searchStore.search(inputValue, sessions.placeFilter);
} else {
searchStore.clear();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, totalItems - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === "Enter") {
e.preventDefault();
selectCurrent();
} else if (e.key === "Escape") {
e.preventDefault();
close();
}
}
function selectCurrent() {
if (showSearchResults) {
const result = searchStore.results[selectedIndex];
if (result) {
selectSearchResult(result);
}
} else {
const session = recentSessions[selectedIndex];
if (session) {
selectSession(session);
}
}
}
/**
* Open a session, the way everything else in the workbench opens one.
*
* The tab comes FIRST and the selection second, and both are needed.
* Selecting alone set `activeSessionId` and opened no editor, so picking a
* session here did nothing visible — and worse, App's route effect
* deselects a session whenever the active tab is not one, so the selection
* this made was thrown away in the same frame. The result was a palette
* that closed on a click and left the screen exactly as it was.
*/
function openSession(
session: Pick<Session, "id" | "display_name" | "first_message" | "project" | "worktree">,
) {
workbench.openSession(session);
sessions.selectSession(session.id);
}
function selectSession(s: Session) {
openSession(s);
close();
}
function selectSearchResult(r: SearchHit) {
if (r.kind === "session") {
openSession({
id: r.session_id,
// What the hit already carries. The session list has more, but a hit
// is enough to label a tab, and waiting on a fetch would leave the
// palette open over a click that has already been made.
display_name: r.name,
first_message: null,
project: r.project,
worktree: r.worktree,
});
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();
}
} else {
// At the line the hit is on, as the hit says.
workbench.openFile(r.path, true, { line: r.line_number });
}
close();
}
function close() {
inputValue = "";
ui.activeModal = null;
}
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("palette-overlay")) {
close();
}
}
$effect(() => {
if (inputRef) {
inputRef.focus();
}
});
$effect(() => {
const _idx = selectedIndex;
tick().then(() => {
const el = document.querySelector(
".palette-results .palette-item.selected",
);
if (el) el.scrollIntoView({ block: "nearest" });
});
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="palette-overlay"
onclick={handleOverlayClick}
onkeydown={handleKeydown}
>
<div class="palette">
<div class="palette-input-wrap">
<AppIcon class="search-icon" name="search" size={14} />
<input
bind:this={inputRef}
type="text"
class="palette-input"
placeholder="Search sessions, messages, and files..."
value={inputValue}
oninput={handleInput}
/>
<kbd class="esc-hint">Esc</kbd>
</div>
<div class="palette-results">
{#if showSearchResults}
{#if searchStore.isSearching && searchStore.results.length === 0}
<div class="palette-empty">Searching...</div>
{:else if !searchStore.isSearching && searchStore.results.length === 0}
<div class="palette-empty">No results</div>
{:else}
{#each searchStore.results as result, i}
{#if result.kind === "session"}
<button
class="palette-item"
class:selected={i === selectedIndex}
onclick={() => selectSearchResult(result)}
onmouseenter={() => (selectedIndex = i)}
>
<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(placeLabel(result), 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();
void copyToClipboard(result.session_id).catch(() => {});
}}
>{stripIdPrefix(result.session_id, result.agent).slice(0, 8)}</span>
</button>
{:else}
<button
class="palette-item palette-item--file"
class:selected={i === selectedIndex}
onclick={() => selectSearchResult(result)}
onmouseenter={() => (selectedIndex = i)}
>
<AppIcon class="file-icon" name="file" size={12} />
<span class="item-body">
<span class="item-name">{result.path.split("/").pop() ?? result.path}</span>
<span class="item-snippet">{result.snippet}</span>
</span>
<span class="item-meta">{result.path} :{result.line_number}</span>
</button>
{/if}
{/each}
{#if searchStore.isSearching}
<div class="palette-searching-more">Searching files…</div>
{/if}
{/if}
{:else}
<div class="palette-section-label">Recent Sessions</div>
{#each recentSessions as session, i}
<button
class="palette-item"
class:selected={i === selectedIndex}
onclick={() => selectSession(session)}
onmouseenter={() => (selectedIndex = i)}
>
<span class="item-dot" style:background={agentColor(session.agent)}></span>
<span class="item-body">
<span class="item-name">{session.first_message
? truncate(session.first_message, 60)
: placeLabel(session)}</span>
</span>
<span class="item-meta">
{formatRelativeTime(session.ended_at ?? session.started_at)}
</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.palette-overlay {
position: fixed;
inset: 0;
background: var(--vscode-widget-shadow);
display: flex;
justify-content: center;
padding-top: 20vh;
z-index: 100;
}
.palette {
width: 560px;
max-height: 400px;
background: var(--vscode-sideBar-background);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wsb-radius-lg);
box-shadow: var(--wb-shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
.palette-input-wrap {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-10) var(--wb-spacing-14);
border-bottom: 1px solid var(--vscode-panel-border);
}
:global(.search-icon) {
flex-shrink: 0;
color: var(--vscode-disabledForeground);
}
.palette-input {
flex: 1;
background: none;
border: none;
font-size: var(--wb-font-size-14);
color: var(--vscode-editor-foreground);
outline: none;
}
.palette-input::placeholder {
color: var(--vscode-disabledForeground);
}
.esc-hint {
font-size: var(--wb-font-size-10);
padding: var(--wb-spacing-1) var(--wb-spacing-5);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wsb-radius-sm);
color: var(--vscode-disabledForeground);
background: var(--vscode-input-background);
font-family: var(--wsb-font-sans);
}
.palette-results {
overflow-y: auto;
flex: 1;
padding: var(--wb-spacing-4) 0;
}
.palette-section-label {
padding: var(--wb-spacing-6) var(--wb-spacing-14) var(--wb-spacing-4);
font-size: var(--wb-font-size-10);
font-weight: 600;
color: var(--vscode-disabledForeground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.palette-item {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
width: 100%;
padding: var(--wb-spacing-6) var(--wb-spacing-14);
text-align: left;
font-size: var(--wb-font-size-13);
color: var(--vscode-editor-foreground);
transition: background 0.05s;
}
.palette-item:hover,
.palette-item.selected {
background: var(--vscode-list-hoverBackground);
}
.item-dot {
width: var(--wb-control-size-6);
height: var(--wb-control-size-6);
border-radius: 50%;
flex-shrink: 0;
}
:global(.file-icon) {
flex-shrink: 0;
color: var(--vscode-disabledForeground);
}
.item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--wb-spacing-1);
}
.item-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--wb-font-size-13);
color: var(--vscode-editor-foreground);
}
.item-snippet {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
}
.item-meta {
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
white-space: nowrap;
flex-shrink: 0;
}
.palette-empty {
padding: var(--wb-spacing-16);
text-align: center;
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-13);
}
.palette-searching-more {
padding: var(--wb-spacing-6) var(--wb-spacing-14);
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
font-style: italic;
}
.item-id {
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
padding: var(--wb-spacing-1) var(--wb-spacing-3);
border-radius: var(--wsb-radius-sm);
}
.item-id:hover {
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
}
</style>