Files
codegraph/ui/src/components/PaletteRows.svelte
T
Colby McHenryandClaude Opus 5 62e0a89b0e feat(ui): the Flow strip — how one symbol reaches another, one card per hop (CG-50)
Ask "how does execute reach getFile" in the search box and the viewer draws the
call path between them, left to right, opening every card at the exact line that
makes the next call. Dynamic-dispatch hops are dashed and name the site they
were wired at; "Read as flow" turns a trail walked by hand into the same strip.

The path finder is NOT new. `codegraph_explore` already leads its answers with
the longest call chain among the symbols an agent named, and a viewer that drew
a different path would get the two quoted against each other in a review. So the
search moved out of `ToolHandler` into `src/graph/named-symbol-flow.ts` and both
callers ride it — same tokens, same overload rules, same synthesized edges. What
stayed behind in `tools.ts` is the prose.

A pinned from/to question is the same search with two options changed, because
both ends being named is the evidence explore's one-unnamed-bridge cap stands in
for: it bridges freely, keeps twelve candidates per endpoint instead of six
(the CLI's own `main` sorts seventh of ten), and searches from both ends at once
— identical paths to the one-way walk on twelve measured pairs, 3-6x faster.

`/api/flow` is deliberately the one endpoint with no cache: its cards carry
source read from disk, and a drift verdict changes without the index changing.

Verified on this repo (`execute` to `rowToFileRecord`, 8 hops; `main` to
`resolveOne`, 7) and on a fresh excalidraw index, where `mutateElement` to
`renderStaticScene` crosses callback, react-render and jsx-child hops and lists
exactly the hops `codegraph_explore` prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 03:19:24 -05:00

156 lines
4.0 KiB
Svelte

<script lang="ts">
/**
* The rows of a palette — shared by the panel under the search box and the
* empty screen's "where to start" list, because they are the same rows and a
* second copy would drift.
*
* Selection is passed in rather than owned here: in the panel it belongs to
* the keyboard, on the empty screen there is none.
*/
import KindGlyph from './KindGlyph.svelte';
import type { Palette, PaletteItem } from '../lib/search-model';
interface Props {
palette: Palette;
/** Index into `palette.items`, or -1 for no keyboard selection. */
selected?: number;
/**
* Set to 'option' when these rows sit inside a listbox (the search panel).
* Left off on the empty screen, where they are just links: `role="option"`
* outside a listbox is a lie a screen reader acts on.
*/
rowRole?: 'option' | undefined;
/** Prefix for each row's DOM id, so a combobox can point at the selected one. */
idPrefix?: string;
onpick: (item: PaletteItem) => void;
onhover?: (index: number) => void;
}
let {
palette,
selected = -1,
rowRole = undefined,
idPrefix = 'palette-row',
onpick,
onhover,
}: Props = $props();
/** Running index into the flat item list, so a row knows its keyboard position. */
function flatIndex(sectionIndex: number, rowIndex: number): number {
let base = 0;
for (let i = 0; i < sectionIndex; i += 1) base += palette.sections[i]?.items.length ?? 0;
return base + rowIndex;
}
</script>
{#each palette.sections as section, s (section.title)}
<div class="head">
<span class="head-title">{section.title}</span>
{#if section.note}<span class="head-note">{section.note}</span>{/if}
</div>
{#each section.items as item, r (item.id)}
{@const index = flatIndex(s, r)}
<button
type="button"
class="row"
class:sel={index === selected}
data-palette-row={index}
id={`${idPrefix}-${index}`}
role={rowRole}
aria-selected={rowRole ? index === selected : undefined}
onmousedown={(event) => {
// mousedown, not click: the input's blur would close the panel first.
event.preventDefault();
onpick(item);
}}
onmouseenter={() => onhover?.(index)}
>
{#if item.type === 'route'}
<KindGlyph kind="route" />
<span class="mid">
<span class="nm">{item.url}</span>
<span class="sig">{item.handler}</span>
</span>
{:else if item.type === 'flow'}
<KindGlyph kind="route" />
<span class="mid">
<span class="nm">{item.name}</span>
</span>
{:else}
<KindGlyph kind={item.node.kind} />
<span class="mid">
<span class="nm">{item.name}</span>
{#if item.meta}<span class="sig">{item.meta}</span>{/if}
</span>
{/if}
<span class="loc">{item.location}</span>
</button>
{/each}
{/each}
<style>
.head {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 10px 4px;
border-bottom: 1px solid var(--rule-faint);
color: var(--ink-3);
font-size: 12px;
}
.head-note {
overflow: hidden;
color: var(--ink-4);
font-size: 11.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.row {
display: grid;
width: 100%;
align-items: baseline;
padding: 6px 10px;
border-bottom: 1px solid var(--rule-faint);
color: var(--ink);
gap: 10px;
grid-template-columns: 18px 1fr auto;
text-align: left;
}
.row:last-child {
border-bottom: 0;
}
.row:hover,
.row.sel {
background: var(--press);
}
.mid {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nm {
font-family: var(--mono);
font-size: 12.5px;
}
.sig {
margin-left: 6px;
color: var(--ink-3);
font-family: var(--mono);
font-size: 11.5px;
}
.loc {
color: var(--ink-3);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
</style>