feat: @agor/stores package (3 stores) + 58 BackendAdapter tests
@agor/stores: - theme.svelte.ts, notifications.svelte.ts, health.svelte.ts extracted - Original files replaced with re-exports (zero consumer changes needed) - pnpm workspace + Vite/tsconfig aliases configured BackendAdapter tests (58 new): - backend-adapter.test.ts: 9 tests (lifecycle, singleton, testing seam) - tauri-adapter.test.ts: 28 tests (invoke mapping, command names, params) - electrobun-adapter.test.ts: 21 tests (RPC names, capabilities, stubs) Total: 523 tests passing (was 465, +58)
This commit is contained in:
parent
5e1fd62ed9
commit
f0850f0785
22 changed files with 1389 additions and 25 deletions
|
|
@ -64,6 +64,9 @@
|
|||
let dmMessages = $state<DM[]>([]);
|
||||
let input = $state('');
|
||||
let loading = $state(false);
|
||||
// Feature 7: Channel member list
|
||||
let channelMembers = $state<Array<{ agentId: string; name: string; role: string }>>([]);
|
||||
let showMembers = $state(false);
|
||||
|
||||
// ── Data fetching ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -119,7 +122,19 @@
|
|||
|
||||
function selectChannel(id: string) {
|
||||
activeChannelId = id;
|
||||
showMembers = false;
|
||||
loadChannelMessages(id);
|
||||
loadChannelMembers(id);
|
||||
}
|
||||
|
||||
// Feature 7: Load channel members
|
||||
async function loadChannelMembers(channelId: string) {
|
||||
try {
|
||||
const res = await appRpc.request['btmsg.getChannelMembers']({ channelId });
|
||||
channelMembers = res.members;
|
||||
} catch (err) {
|
||||
console.error('[CommsTab] loadChannelMembers:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDm(otherId: string) {
|
||||
|
|
@ -156,22 +171,38 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── Init + polling ───────────────────────────────────────────────────
|
||||
// ── Init + event-driven updates (Feature 4) ─────────────────────────
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Feature 4: Listen for push events
|
||||
function onNewMessage(payload: { groupId: string; channelId?: string }) {
|
||||
if (mode === 'channels' && activeChannelId) {
|
||||
if (!payload.channelId || payload.channelId === activeChannelId) {
|
||||
loadChannelMessages(activeChannelId);
|
||||
}
|
||||
} else if (mode === 'dms' && activeDmAgentId) {
|
||||
loadDmMessages(activeDmAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadChannels();
|
||||
loadAgents();
|
||||
appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
|
||||
// Feature 4: Fallback 30s poll for missed events
|
||||
pollTimer = setInterval(() => {
|
||||
if (mode === 'channels' && activeChannelId) {
|
||||
loadChannelMessages(activeChannelId);
|
||||
} else if (mode === 'dms' && activeDmAgentId) {
|
||||
loadDmMessages(activeDmAgentId);
|
||||
}
|
||||
}, 5000);
|
||||
}, 30000);
|
||||
|
||||
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||
return () => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
appRpc.removeMessageListener?.('btmsg.newMessage', onNewMessage);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -264,6 +295,20 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Feature 7: Channel member list toggle -->
|
||||
{#if mode === 'channels' && activeChannelId}
|
||||
<button class="members-toggle" onclick={() => showMembers = !showMembers}>
|
||||
Members ({channelMembers.length})
|
||||
</button>
|
||||
{#if showMembers}
|
||||
<div class="members-list">
|
||||
{#each channelMembers as m}
|
||||
<span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Input bar -->
|
||||
<div class="msg-input-bar">
|
||||
<input
|
||||
|
|
@ -518,4 +563,42 @@
|
|||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Feature 7: Member list */
|
||||
.members-toggle {
|
||||
background: var(--ctp-surface0);
|
||||
border: none;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay1);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: var(--ui-font-family);
|
||||
}
|
||||
|
||||
.members-toggle:hover { color: var(--ctp-text); }
|
||||
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.member-chip {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-text);
|
||||
background: var(--ctp-mantle);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.member-role {
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -170,6 +170,11 @@
|
|||
fileEncoding = result.encoding;
|
||||
fileSize = result.size;
|
||||
editorContent = fileContent;
|
||||
// Feature 2: Record mtime at read time
|
||||
try {
|
||||
const stat = await appRpc.request["files.stat"]({ path: filePath });
|
||||
if (token === fileRequestToken && !stat.error) readMtimeMs = stat.mtimeMs;
|
||||
} catch { /* non-critical */ }
|
||||
} catch (err) {
|
||||
if (token !== fileRequestToken) return;
|
||||
fileError = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -178,9 +183,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Save current file. */
|
||||
/** Save current file. Feature 2: Check mtime before write for conflict detection. */
|
||||
async function saveFile() {
|
||||
if (!selectedFile || !isDirty) return;
|
||||
try {
|
||||
// Feature 2: Check if file was modified externally since we read it
|
||||
if (readMtimeMs > 0) {
|
||||
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
||||
if (!stat.error && stat.mtimeMs > readMtimeMs) {
|
||||
showConflictDialog = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await doSave();
|
||||
} catch (err) {
|
||||
console.error('[files.write]', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-save, bypassing conflict check. */
|
||||
async function doSave() {
|
||||
if (!selectedFile) return;
|
||||
try {
|
||||
const result = await appRpc.request["files.write"]({
|
||||
path: selectedFile,
|
||||
|
|
@ -189,6 +212,10 @@
|
|||
if (result.ok) {
|
||||
isDirty = false;
|
||||
fileContent = editorContent;
|
||||
showConflictDialog = false;
|
||||
// Update mtime after successful save
|
||||
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
|
||||
if (!stat.error) readMtimeMs = stat.mtimeMs;
|
||||
} else if (result.error) {
|
||||
console.error('[files.write]', result.error);
|
||||
}
|
||||
|
|
@ -197,6 +224,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Reload file from disk (discard local changes). */
|
||||
async function reloadFile() {
|
||||
showConflictDialog = false;
|
||||
if (selectedFile) {
|
||||
isDirty = false;
|
||||
const saved = selectedFile;
|
||||
selectedFile = null;
|
||||
await selectFile(saved);
|
||||
}
|
||||
}
|
||||
|
||||
function onEditorChange(newContent: string) {
|
||||
editorContent = newContent;
|
||||
isDirty = newContent !== fileContent;
|
||||
|
|
@ -282,6 +320,21 @@
|
|||
{@render renderEntries(cwd, 0)}
|
||||
</div>
|
||||
|
||||
<!-- Feature 2: Conflict dialog -->
|
||||
{#if showConflictDialog}
|
||||
<div class="conflict-overlay">
|
||||
<div class="conflict-dialog">
|
||||
<p class="conflict-title">File modified externally</p>
|
||||
<p class="conflict-desc">This file was changed on disk since you opened it.</p>
|
||||
<div class="conflict-actions">
|
||||
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
|
||||
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
|
||||
<button class="conflict-btn cancel" onclick={() => showConflictDialog = false}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Viewer panel -->
|
||||
<div class="fb-viewer">
|
||||
{#if !selectedFile}
|
||||
|
|
@ -346,6 +399,7 @@
|
|||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.8125rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Tree panel ── */
|
||||
|
|
@ -521,4 +575,58 @@
|
|||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Feature 2: Conflict dialog */
|
||||
.conflict-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.conflict-dialog {
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.conflict-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
.conflict-desc {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.conflict-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.conflict-btn {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conflict-btn.overwrite { border-color: var(--ctp-red); color: var(--ctp-red); }
|
||||
.conflict-btn.reload { border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||
.conflict-btn:hover { background: var(--ctp-surface1); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import MarketplaceTab from './settings/MarketplaceTab.svelte';
|
||||
import KeyboardSettings from './settings/KeyboardSettings.svelte';
|
||||
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
|
||||
import DiagnosticsTab from './settings/DiagnosticsTab.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard';
|
||||
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard' | 'diagnostics';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
|
||||
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
|
||||
{ id: 'diagnostics', label: 'Diagnostics', icon: '📊' },
|
||||
];
|
||||
|
||||
let activeCategory = $state<CategoryId>('appearance');
|
||||
|
|
@ -105,6 +107,8 @@
|
|||
<KeyboardSettings />
|
||||
{:else if activeCategory === 'marketplace'}
|
||||
<MarketplaceTab />
|
||||
{:else if activeCategory === 'diagnostics'}
|
||||
<DiagnosticsTab />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -168,14 +168,24 @@
|
|||
dragOverCol = null;
|
||||
}
|
||||
|
||||
// ── Init + polling ───────────────────────────────────────────────────
|
||||
// ── Init + event-driven updates (Feature 4) ─────────────────────────
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Feature 4: Listen for push events, fallback to 30s poll
|
||||
function onTaskChanged(payload: { groupId: string }) {
|
||||
if (!payload.groupId || payload.groupId === groupId) loadTasks();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadTasks();
|
||||
pollTimer = setInterval(loadTasks, 5000);
|
||||
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||
appRpc.addMessageListener('bttask.changed', onTaskChanged);
|
||||
// Feature 4: Fallback 30s poll for missed events
|
||||
pollTimer = setInterval(loadTasks, 30000);
|
||||
return () => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
appRpc.removeMessageListener?.('bttask.changed', onTaskChanged);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -136,8 +136,16 @@
|
|||
|
||||
// ── Send user input to daemon ──────────────────────────────────────────
|
||||
|
||||
// Feature 5: Max terminal paste chunk (64KB) — truncate with warning
|
||||
const MAX_PASTE_CHUNK = 64 * 1024;
|
||||
term.onData((data: string) => {
|
||||
appRpc.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
|
||||
let payload = data;
|
||||
if (payload.length > MAX_PASTE_CHUNK) {
|
||||
console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`);
|
||||
payload = payload.slice(0, MAX_PASTE_CHUNK);
|
||||
term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m');
|
||||
}
|
||||
appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => {
|
||||
console.error('[pty.write] error:', err);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
271
ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte
Normal file
271
ui-electrobun/src/mainview/settings/DiagnosticsTab.svelte
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<script lang="ts">
|
||||
import { appRpc } from '../rpc.ts';
|
||||
import { getHealthAggregates } from '../health-store.svelte.ts';
|
||||
import { getActiveTools, getToolHistogram } from '../health-store.svelte.ts';
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────
|
||||
|
||||
let ptyConnected = $state(false);
|
||||
let relayConnections = $state(0);
|
||||
let activeSidecars = $state(0);
|
||||
let rpcCallCount = $state(0);
|
||||
let droppedEvents = $state(0);
|
||||
let lastRefresh = $state(Date.now());
|
||||
|
||||
let health = $derived(getHealthAggregates());
|
||||
let activeTools = $derived(getActiveTools());
|
||||
let toolHistogram = $derived(getToolHistogram());
|
||||
|
||||
// ── Data fetching ────────────────────────────────────────────────────
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await appRpc.request['diagnostics.stats']({});
|
||||
ptyConnected = res.ptyConnected;
|
||||
relayConnections = res.relayConnections;
|
||||
activeSidecars = res.activeSidecars;
|
||||
rpcCallCount = res.rpcCallCount;
|
||||
droppedEvents = res.droppedEvents;
|
||||
lastRefresh = Date.now();
|
||||
} catch (err) {
|
||||
console.error('[Diagnostics] refresh:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
// ── Init + polling ────────────────────────────────────────────────────
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
refresh();
|
||||
pollTimer = setInterval(refresh, 5000);
|
||||
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="diagnostics">
|
||||
<h3 class="sh">Transport Diagnostics</h3>
|
||||
|
||||
<!-- Connection status -->
|
||||
<div class="diag-section">
|
||||
<h4 class="diag-label">Connections</h4>
|
||||
<div class="diag-grid">
|
||||
<span class="diag-key">PTY daemon</span>
|
||||
<span class="diag-val" class:ok={ptyConnected} class:err={!ptyConnected}>
|
||||
{ptyConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
|
||||
<span class="diag-key">Relay connections</span>
|
||||
<span class="diag-val">{relayConnections}</span>
|
||||
|
||||
<span class="diag-key">Active sidecars</span>
|
||||
<span class="diag-val">{activeSidecars}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health aggregates -->
|
||||
<div class="diag-section">
|
||||
<h4 class="diag-label">Agent fleet</h4>
|
||||
<div class="diag-grid">
|
||||
<span class="diag-key">Running</span>
|
||||
<span class="diag-val ok">{health.running}</span>
|
||||
|
||||
<span class="diag-key">Idle</span>
|
||||
<span class="diag-val">{health.idle}</span>
|
||||
|
||||
<span class="diag-key">Stalled</span>
|
||||
<span class="diag-val" class:err={health.stalled > 0}>{health.stalled}</span>
|
||||
|
||||
<span class="diag-key">Burn rate</span>
|
||||
<span class="diag-val">${health.totalBurnRatePerHour.toFixed(2)}/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature 10: Active tools -->
|
||||
{#if activeTools.length > 0}
|
||||
<div class="diag-section">
|
||||
<h4 class="diag-label">Active tools</h4>
|
||||
<div class="tool-list">
|
||||
{#each activeTools as tool}
|
||||
<div class="tool-item">
|
||||
<span class="tool-name">{tool.toolName}</span>
|
||||
<span class="tool-elapsed">{formatDuration(Date.now() - tool.startTime)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Feature 10: Tool duration histogram -->
|
||||
{#if toolHistogram.length > 0}
|
||||
<div class="diag-section">
|
||||
<h4 class="diag-label">Tool duration (avg)</h4>
|
||||
<div class="histogram">
|
||||
{#each toolHistogram as entry}
|
||||
{@const maxMs = Math.max(...toolHistogram.map(e => e.avgMs))}
|
||||
<div class="histo-row">
|
||||
<span class="histo-name">{entry.toolName}</span>
|
||||
<div class="histo-bar-wrap">
|
||||
<div
|
||||
class="histo-bar"
|
||||
style:width="{maxMs > 0 ? (entry.avgMs / maxMs * 100) : 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="histo-val">{formatDuration(entry.avgMs)} ({entry.count}x)</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="diag-footer">
|
||||
<span class="diag-key">Last refresh</span>
|
||||
<span class="diag-val">{new Date(lastRefresh).toLocaleTimeString()}</span>
|
||||
<button class="refresh-btn" onclick={refresh}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.diagnostics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sh {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.diag-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.diag-label {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.diag-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.diag-key { color: var(--ctp-subtext0); }
|
||||
.diag-val {
|
||||
color: var(--ctp-text);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.diag-val.ok { color: var(--ctp-green); }
|
||||
.diag-val.err { color: var(--ctp-red); }
|
||||
|
||||
.tool-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
color: var(--ctp-blue);
|
||||
font-family: var(--term-font-family, monospace);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-elapsed { color: var(--ctp-overlay1); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.histogram {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.histo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.histo-name {
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--ctp-subtext0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
}
|
||||
|
||||
.histo-bar-wrap {
|
||||
flex: 1;
|
||||
height: 0.375rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.125rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.histo-bar {
|
||||
height: 100%;
|
||||
background: var(--ctp-mauve);
|
||||
border-radius: 0.125rem;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.histo-val {
|
||||
width: 5.5rem;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
color: var(--ctp-overlay1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diag-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
margin-left: auto;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
</style>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { appRpc } from '../rpc.ts';
|
||||
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
|
||||
import { setRetentionConfig } from '../agent-store.svelte.ts';
|
||||
|
||||
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;
|
||||
type AnchorScale = typeof ANCHOR_SCALES[number];
|
||||
|
|
@ -34,6 +35,10 @@
|
|||
let selectedId = $state('p1');
|
||||
let proj = $derived(projects.find(p => p.id === selectedId)!);
|
||||
|
||||
// Feature 6: Retention settings
|
||||
let sessionRetentionCount = $state(5);
|
||||
let sessionRetentionDays = $state(30);
|
||||
|
||||
function updateProj(patch: Partial<ProjectConfig>) {
|
||||
projects = projects.map(p => p.id === selectedId ? { ...p, ...patch } : p);
|
||||
const updated = projects.find(p => p.id === selectedId)!;
|
||||
|
|
@ -43,6 +48,17 @@
|
|||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// Feature 6: Save retention settings
|
||||
function updateRetention(key: string, value: number) {
|
||||
if (key === 'count') sessionRetentionCount = value;
|
||||
else sessionRetentionDays = value;
|
||||
setRetentionConfig(sessionRetentionCount, sessionRetentionDays);
|
||||
appRpc?.request['settings.set']({
|
||||
key: key === 'count' ? 'session_retention_count' : 'session_retention_days',
|
||||
value: String(value),
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!appRpc) return;
|
||||
const res = await appRpc.request['settings.getProjects']({}).catch(() => ({ projects: [] }));
|
||||
|
|
@ -52,6 +68,12 @@
|
|||
});
|
||||
if (loaded.length > 0) projects = loaded;
|
||||
}
|
||||
// Feature 6: Load retention settings
|
||||
try {
|
||||
const { settings } = await appRpc.request['settings.getAll']({});
|
||||
if (settings['session_retention_count']) sessionRetentionCount = parseInt(settings['session_retention_count'], 10) || 5;
|
||||
if (settings['session_retention_days']) sessionRetentionDays = parseInt(settings['session_retention_days'], 10) || 30;
|
||||
} catch { /* use defaults */ }
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -110,6 +132,23 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Feature 6: Session retention controls -->
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Session retention</h3>
|
||||
<div class="slider-row">
|
||||
<span class="lbl">Keep last</span>
|
||||
<input type="range" min="1" max="20" step="1" value={sessionRetentionCount}
|
||||
oninput={e => updateRetention('count', parseInt((e.target as HTMLInputElement).value, 10))}
|
||||
/>
|
||||
<span class="slider-val">{sessionRetentionCount}</span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="lbl">Max age</span>
|
||||
<input type="range" min="1" max="90" step="1" value={sessionRetentionDays}
|
||||
oninput={e => updateRetention('days', parseInt((e.target as HTMLInputElement).value, 10))}
|
||||
/>
|
||||
<span class="slider-val">{sessionRetentionDays}d</span>
|
||||
</div>
|
||||
|
||||
<h3 class="sh" style="margin-top: 0.625rem;">Custom context</h3>
|
||||
<textarea
|
||||
class="prompt"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue