feat(session-anchors): add pin button, anchor re-injection, and ContextTab UI
This commit is contained in:
parent
a9e94fc154
commit
ccce2b6005
4 changed files with 367 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue