feat(session-anchors): add pin button, anchor re-injection, and ContextTab UI

This commit is contained in:
Hibryda 2026-03-11 02:43:06 +01:00
parent a9e94fc154
commit ccce2b6005
4 changed files with 367 additions and 3 deletions

View file

@ -11,6 +11,10 @@
import { focusPane } from '../../stores/layout.svelte';
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge';
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
import { estimateTokens } from '../../utils/anchor-serializer';
import type { SessionAnchor } from '../../types/anchors';
import { notify } from '../../stores/notifications.svelte';
import AgentTree from './AgentTree.svelte';
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
import type {
@ -43,6 +47,7 @@
interface Props {
sessionId: string;
projectId?: string;
prompt?: string;
cwd?: string;
profile?: string;
@ -51,7 +56,7 @@
onExit?: () => void;
}
let { sessionId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, onExit }: Props = $props();
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, onExit }: Props = $props();
let session = $derived(getAgentSession(sessionId));
let inputPrompt = $state(initialPrompt);
@ -158,6 +163,23 @@
}
const profile = profileName ? profiles.find(p => p.name === profileName) : undefined;
// Build system prompt with anchor re-injection if available
let systemPrompt: string | undefined;
if (projectId) {
const anchors = getInjectableAnchors(projectId);
if (anchors.length > 0) {
// Anchors store pre-serialized content — join them directly
systemPrompt = anchors.map(a => a.content).join('\n');
// Warn if Ollama provider — default context windows (2K-4K) may be too small
if (providerId === 'ollama') {
const anchorTokens = anchors.reduce((sum, a) => sum + a.estimatedTokens, 0);
notify('warning', `Ollama: injecting ~${anchorTokens} anchor tokens into system prompt. Ensure num_ctx >= 8192 to avoid truncation.`);
}
}
}
await queryAgent({
provider: providerId,
session_id: sessionId,
@ -167,6 +189,7 @@
resume_session_id: resumeId,
setting_sources: ['user', 'project'],
claude_config_dir: profile?.config_dir,
system_prompt: systemPrompt,
});
inputPrompt = '';
if (promptRef) {
@ -257,6 +280,36 @@
}
});
// --- Anchor pinning ---
let projectAnchorIds = $derived(
projectId ? new Set(getProjectAnchors(projectId).map(a => a.messageId)) : new Set<string>()
);
function isMessagePinned(msgId: string): boolean {
return projectAnchorIds.has(msgId);
}
async function togglePin(msgId: string, content: string) {
if (!projectId) return;
if (isMessagePinned(msgId)) {
const anchors = getProjectAnchors(projectId);
const anchor = anchors.find(a => a.messageId === msgId);
if (anchor) await removeAnchor(projectId, anchor.id);
} else {
const anchor: SessionAnchor = {
id: crypto.randomUUID(),
projectId,
messageId: msgId,
anchorType: 'pinned',
content,
estimatedTokens: estimateTokens(content),
turnIndex: -1, // Manual pins don't track turn index
createdAt: Math.floor(Date.now() / 1000),
};
await addAnchors(projectId, [anchor]);
}
}
function formatToolInput(input: unknown): string {
if (typeof input === 'string') return input;
try {
@ -360,6 +413,19 @@
<summary>
<span class="chevron" aria-hidden="true"></span>
<span class="text-preview">{firstLine}{firstLine.length >= 120 ? '...' : ''}</span>
{#if projectId}
<button
class="pin-btn"
class:pinned={isMessagePinned(msg.id)}
title={isMessagePinned(msg.id) ? 'Unpin message' : 'Pin as anchor'}
onclick={(e: MouseEvent) => { e.stopPropagation(); togglePin(msg.id, textContent); }}
aria-label={isMessagePinned(msg.id) ? 'Unpin message' : 'Pin as anchor'}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={isMessagePinned(msg.id) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
<path d="M12 2L12 12M12 12L8 8M12 12L16 8M5 21L12 15L19 21" />
</svg>
</button>
{/if}
</summary>
<div class="msg-text markdown-body">{@html renderMarkdown(textContent)}</div>
</details>
@ -763,6 +829,33 @@
display: none;
}
/* === Pin button === */
.pin-btn {
opacity: 0;
background: none;
border: none;
padding: 0.125em 0.25em;
cursor: pointer;
color: var(--ctp-overlay0);
transition: opacity 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
}
.pin-btn.pinned {
opacity: 1;
color: var(--ctp-yellow);
}
.msg-text-collapsible summary:hover .pin-btn,
.pin-btn:focus-visible {
opacity: 1;
}
.pin-btn:hover {
color: var(--ctp-yellow);
}
/* === Text messages (markdown) === */
.msg-text {
word-break: break-word;

View file

@ -18,6 +18,7 @@
} from '../../stores/agents.svelte';
import type { AgentMessage } from '../../adapters/claude-messages';
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
import AgentPane from '../Agent/AgentPane.svelte';
interface Props {
@ -73,6 +74,8 @@
sessionId = crypto.randomUUID();
} finally {
loading = false;
// Load persisted anchors for this project
loadAnchorsForProject(project.id);
// Register session -> project mapping for persistence + health tracking
registerSessionProject(sessionId, project.id, providerId);
trackProject(project.id, sessionId);
@ -122,6 +125,7 @@
{:else}
<AgentPane
{sessionId}
projectId={project.id}
cwd={project.cwd}
profile={project.profile || undefined}
provider={providerId}

View file

@ -2,12 +2,22 @@
import { getAgentSession, getTotalCost, type AgentSession } from '../../stores/agents.svelte';
import type { AgentMessage, ToolCallContent, CostContent, CompactionContent } from '../../adapters/claude-messages';
import { extractFilePaths } from '../../utils/tool-files';
import {
getProjectAnchors,
getInjectableAnchors,
getInjectableTokenCount,
removeAnchor,
changeAnchorType,
} from '../../stores/anchors.svelte';
import { DEFAULT_ANCHOR_SETTINGS, MAX_ANCHOR_TOKEN_BUDGET } from '../../types/anchors';
import type { SessionAnchor, AnchorType } from '../../types/anchors';
interface Props {
sessionId: string | null;
projectId?: string;
}
let { sessionId }: Props = $props();
let { sessionId, projectId }: Props = $props();
// Reactive session data
let session = $derived(sessionId ? getAgentSession(sessionId) : undefined);
@ -179,6 +189,40 @@
return Array.from(refs.values()).sort((a, b) => b.count - a.count);
});
// --- Anchors ---
let anchors = $derived(projectId ? getProjectAnchors(projectId) : []);
let injectableAnchors = $derived(projectId ? getInjectableAnchors(projectId) : []);
let anchorTokens = $derived(projectId ? getInjectableTokenCount(projectId) : 0);
let anchorBudget = $derived(DEFAULT_ANCHOR_SETTINGS.anchorTokenBudget);
let anchorBudgetPct = $derived(anchorBudget > 0 ? Math.min((anchorTokens / anchorBudget) * 100, 100) : 0);
function anchorTypeLabel(t: AnchorType): string {
switch (t) {
case 'auto': return 'Auto';
case 'pinned': return 'Pinned';
case 'promoted': return 'Promoted';
}
}
function anchorTypeColor(t: AnchorType): string {
switch (t) {
case 'auto': return 'var(--ctp-blue)';
case 'pinned': return 'var(--ctp-yellow)';
case 'promoted': return 'var(--ctp-green)';
}
}
async function handlePromote(anchor: SessionAnchor) {
if (!projectId) return;
const newType: AnchorType = anchor.anchorType === 'pinned' ? 'promoted' : 'pinned';
await changeAnchorType(projectId, anchor.id, newType);
}
async function handleRemoveAnchor(anchor: SessionAnchor) {
if (!projectId) return;
await removeAnchor(projectId, anchor.id);
}
// --- Helpers ---
function estimateTokens(msg: AgentMessage): number {
const content = msg.content;
@ -671,6 +715,81 @@
</div>
</div>
<!-- Session Anchors -->
{#if anchors.length > 0}
<div class="anchors-section">
<div class="section-header">
<span class="section-title">Session Anchors</span>
<span class="section-count">{anchors.length}</span>
{#if injectableAnchors.length > 0}
<span class="anchor-injectable-badge" title="Re-injected into system prompt on next query">
{injectableAnchors.length} injectable
</span>
{/if}
</div>
<!-- Budget meter -->
<div class="anchor-budget">
<div class="anchor-budget-header">
<span class="anchor-budget-label">Anchor Budget</span>
<span class="anchor-budget-usage">{formatTokens(anchorTokens)} / {formatTokens(anchorBudget)}</span>
</div>
<div class="anchor-budget-bar">
<div
class="anchor-budget-fill"
class:warn={anchorBudgetPct > 75}
class:full={anchorBudgetPct >= 100}
style="width: {anchorBudgetPct}%"
></div>
</div>
</div>
<!-- Anchor list -->
<div class="anchor-list">
{#each anchors as anchor (anchor.id)}
<div class="anchor-item">
<span class="anchor-type-dot" style="background: {anchorTypeColor(anchor.anchorType)}"></span>
<span class="anchor-type-label" style="color: {anchorTypeColor(anchor.anchorType)}">{anchorTypeLabel(anchor.anchorType)}</span>
<span class="anchor-content" title={anchor.content}>{anchor.content.split('\n')[0].slice(0, 60)}{anchor.content.length > 60 ? '...' : ''}</span>
<span class="anchor-tokens">{formatTokens(anchor.estimatedTokens)}</span>
<div class="anchor-actions">
{#if anchor.anchorType === 'pinned'}
<button
class="anchor-action-btn"
title="Promote to injectable (re-injected on next query)"
onclick={() => handlePromote(anchor)}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--ctp-green)" stroke-width="2">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
{:else if anchor.anchorType === 'promoted'}
<button
class="anchor-action-btn"
title="Demote to pinned (display only)"
onclick={() => handlePromote(anchor)}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--ctp-yellow)" stroke-width="2">
<path d="M12 5v14M5 12l7 7 7-7"/>
</svg>
</button>
{/if}
<button
class="anchor-action-btn anchor-remove-btn"
title="Remove anchor"
onclick={() => handleRemoveAnchor(anchor)}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- File References -->
{#if fileRefs.length > 0}
<div class="files-section">
@ -1432,4 +1551,152 @@
.graph-scroll svg {
display: block;
}
/* Session Anchors */
.anchors-section {
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.anchor-injectable-badge {
font-size: 0.525rem;
padding: 0.0625rem 0.3125rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
color: var(--ctp-green);
font-family: var(--term-font-family, monospace);
margin-left: auto;
}
.anchor-budget {
margin-bottom: 0.375rem;
}
.anchor-budget-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.anchor-budget-label {
font-size: 0.6rem;
color: var(--ctp-overlay1);
}
.anchor-budget-usage {
font-size: 0.575rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-overlay1);
}
.anchor-budget-bar {
height: 0.375rem;
border-radius: 0.1875rem;
background: var(--ctp-surface0);
overflow: hidden;
}
.anchor-budget-fill {
height: 100%;
border-radius: 0.1875rem;
background: var(--ctp-blue);
transition: width 0.3s ease;
}
.anchor-budget-fill.warn {
background: var(--ctp-yellow);
}
.anchor-budget-fill.full {
background: var(--ctp-red);
}
.anchor-list {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 10rem;
overflow-y: auto;
}
.anchor-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.1875rem 0.375rem;
border-radius: 0.1875rem;
transition: background 0.1s;
}
.anchor-item:hover {
background: var(--ctp-surface0);
}
.anchor-type-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
flex-shrink: 0;
}
.anchor-type-label {
font-size: 0.525rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
min-width: 3.25rem;
}
.anchor-content {
font-size: 0.6rem;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.anchor-tokens {
font-size: 0.55rem;
font-family: var(--term-font-family, monospace);
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.anchor-actions {
display: flex;
gap: 0.125rem;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.anchor-item:hover .anchor-actions {
opacity: 1;
}
.anchor-action-btn {
background: none;
border: none;
padding: 0.125rem;
cursor: pointer;
color: var(--ctp-overlay0);
border-radius: 0.125rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s;
}
.anchor-action-btn:hover {
background: var(--ctp-surface1);
}
.anchor-remove-btn:hover {
color: var(--ctp-red);
}
</style>

View file

@ -157,7 +157,7 @@
<ProjectFiles cwd={project.cwd} projectName={project.name} />
</div>
<div class="content-pane" style:display={activeTab === 'context' ? 'flex' : 'none'}>
<ContextTab sessionId={mainSessionId} />
<ContextTab sessionId={mainSessionId} projectId={project.id} />
</div>
<!-- PERSISTED-LAZY: mount on first activation, then toggle via display -->