From ccce2b6005edff3f8904d4c465e8d2ea22f39c0e Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 02:43:06 +0100 Subject: [PATCH] feat(session-anchors): add pin button, anchor re-injection, and ContextTab UI --- v2/src/lib/components/Agent/AgentPane.svelte | 95 ++++++- .../components/Workspace/AgentSession.svelte | 4 + .../components/Workspace/ContextTab.svelte | 269 +++++++++++++++++- .../components/Workspace/ProjectBox.svelte | 2 +- 4 files changed, 367 insertions(+), 3 deletions(-) diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index 3934e6a..86eb61c 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -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() + ); + + 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 @@ {firstLine}{firstLine.length >= 120 ? '...' : ''} + {#if projectId} + + {/if}
{@html renderMarkdown(textContent)}
@@ -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; diff --git a/v2/src/lib/components/Workspace/AgentSession.svelte b/v2/src/lib/components/Workspace/AgentSession.svelte index e9b1107..12958d0 100644 --- a/v2/src/lib/components/Workspace/AgentSession.svelte +++ b/v2/src/lib/components/Workspace/AgentSession.svelte @@ -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} 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 @@ + + {#if anchors.length > 0} +
+
+ Session Anchors + {anchors.length} + {#if injectableAnchors.length > 0} + + {injectableAnchors.length} injectable + + {/if} +
+ + +
+
+ Anchor Budget + {formatTokens(anchorTokens)} / {formatTokens(anchorBudget)} +
+
+
75} + class:full={anchorBudgetPct >= 100} + style="width: {anchorBudgetPct}%" + >
+
+
+ + +
+ {#each anchors as anchor (anchor.id)} +
+ + {anchorTypeLabel(anchor.anchorType)} + {anchor.content.split('\n')[0].slice(0, 60)}{anchor.content.length > 60 ? '...' : ''} + {formatTokens(anchor.estimatedTokens)} +
+ {#if anchor.anchorType === 'pinned'} + + {:else if anchor.anchorType === 'promoted'} + + {/if} + +
+
+ {/each} +
+
+ {/if} + {#if fileRefs.length > 0}
@@ -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); + } diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte index ac2760a..07ec3ec 100644 --- a/v2/src/lib/components/Workspace/ProjectBox.svelte +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -157,7 +157,7 @@
- +