feat(v3): implement session continuity, workspace teardown, StatusBar rewrite, subagent routing fix
P6: persistSessionForProject() saves agent state + messages to SQLite on session complete. registerSessionProject() maps sessionId -> projectId. ClaudeSession restoreMessagesFromRecords() restores cached messages on mount. P7: clearAllAgentSessions() clears sessions on group switch. switchGroup() calls clearAllAgentSessions() + resets terminal tabs. P10: StatusBar rewritten for workspace store (group name, project count, agent count, "BTerminal v3"). Subagent routing fixed: project-scoped sessions skip layout pane creation (render in TeamAgentsPanel instead).
This commit is contained in:
parent
9766a480ed
commit
e0056f811f
5 changed files with 156 additions and 31 deletions
|
|
@ -11,15 +11,28 @@ import {
|
||||||
appendAgentMessages,
|
appendAgentMessages,
|
||||||
updateAgentCost,
|
updateAgentCost,
|
||||||
getAgentSessions,
|
getAgentSessions,
|
||||||
|
getAgentSession,
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
findChildByToolUseId,
|
findChildByToolUseId,
|
||||||
} from './stores/agents.svelte';
|
} from './stores/agents.svelte';
|
||||||
import { addPane, getPanes } from './stores/layout.svelte';
|
import { addPane, getPanes } from './stores/layout.svelte';
|
||||||
import { notify } from './stores/notifications.svelte';
|
import { notify } from './stores/notifications.svelte';
|
||||||
|
import {
|
||||||
|
saveProjectAgentState,
|
||||||
|
saveAgentMessages,
|
||||||
|
type AgentMessageRecord,
|
||||||
|
} from './adapters/groups-bridge';
|
||||||
|
|
||||||
let unlistenMsg: (() => void) | null = null;
|
let unlistenMsg: (() => void) | null = null;
|
||||||
let unlistenExit: (() => void) | null = null;
|
let unlistenExit: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Map sessionId -> projectId for persistence routing
|
||||||
|
const sessionProjectMap = new Map<string, string>();
|
||||||
|
|
||||||
|
export function registerSessionProject(sessionId: string, projectId: string): void {
|
||||||
|
sessionProjectMap.set(sessionId, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Sidecar liveness — checked by UI components
|
// Sidecar liveness — checked by UI components
|
||||||
let sidecarAlive = true;
|
let sidecarAlive = true;
|
||||||
|
|
||||||
|
|
@ -160,6 +173,8 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
||||||
updateAgentStatus(sessionId, 'done');
|
updateAgentStatus(sessionId, 'done');
|
||||||
notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`);
|
notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`);
|
||||||
}
|
}
|
||||||
|
// Persist session state for project-scoped sessions
|
||||||
|
persistSessionForProject(sessionId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,15 +235,60 @@ function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void {
|
||||||
});
|
});
|
||||||
updateAgentStatus(childId, 'running');
|
updateAgentStatus(childId, 'running');
|
||||||
|
|
||||||
// Create layout pane, auto-grouped under parent's title
|
// For project-scoped sessions, subagents render in TeamAgentsPanel (no layout pane)
|
||||||
const parentPane = getPanes().find(p => p.id === parentSessionId);
|
// For non-project sessions (detached mode), create a layout pane
|
||||||
const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`;
|
if (!sessionProjectMap.has(parentSessionId)) {
|
||||||
addPane({
|
const parentPane = getPanes().find(p => p.id === parentSessionId);
|
||||||
id: childId,
|
const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`;
|
||||||
type: 'agent',
|
addPane({
|
||||||
title: `Sub: ${label}`,
|
id: childId,
|
||||||
group: groupName,
|
type: 'agent',
|
||||||
});
|
title: `Sub: ${label}`,
|
||||||
|
group: groupName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist session state + messages to SQLite for the project that owns this session */
|
||||||
|
async function persistSessionForProject(sessionId: string): Promise<void> {
|
||||||
|
const projectId = sessionProjectMap.get(sessionId);
|
||||||
|
if (!projectId) return; // Not a project-scoped session
|
||||||
|
|
||||||
|
const session = getAgentSession(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save agent state
|
||||||
|
await saveProjectAgentState({
|
||||||
|
project_id: projectId,
|
||||||
|
last_session_id: sessionId,
|
||||||
|
sdk_session_id: session.sdkSessionId ?? null,
|
||||||
|
status: session.status,
|
||||||
|
cost_usd: session.costUsd,
|
||||||
|
input_tokens: session.inputTokens,
|
||||||
|
output_tokens: session.outputTokens,
|
||||||
|
last_prompt: session.prompt,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save messages
|
||||||
|
const records: AgentMessageRecord[] = session.messages.map((m, i) => ({
|
||||||
|
id: i,
|
||||||
|
session_id: sessionId,
|
||||||
|
project_id: projectId,
|
||||||
|
sdk_session_id: session.sdkSessionId ?? null,
|
||||||
|
message_type: m.type,
|
||||||
|
content: JSON.stringify(m.content),
|
||||||
|
parent_id: m.parentId ?? null,
|
||||||
|
created_at: Date.now(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to persist agent session:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAgentDispatcher(): void {
|
export function stopAgentDispatcher(): void {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getPanes } from '../../stores/layout.svelte';
|
|
||||||
import { getAgentSessions } from '../../stores/agents.svelte';
|
import { getAgentSessions } from '../../stores/agents.svelte';
|
||||||
|
import { getActiveGroup, getEnabledProjects, getActiveGroupId } from '../../stores/workspace.svelte';
|
||||||
|
|
||||||
let panes = $derived(getPanes());
|
|
||||||
let agentSessions = $derived(getAgentSessions());
|
let agentSessions = $derived(getAgentSessions());
|
||||||
|
let activeGroup = $derived(getActiveGroup());
|
||||||
|
let enabledProjects = $derived(getEnabledProjects());
|
||||||
|
|
||||||
let activeAgents = $derived(agentSessions.filter(s => s.status === 'running' || s.status === 'starting').length);
|
let activeAgents = $derived(agentSessions.filter(s => s.status === 'running' || s.status === 'starting').length);
|
||||||
let totalCost = $derived(agentSessions.reduce((sum, s) => sum + s.costUsd, 0));
|
let totalCost = $derived(agentSessions.reduce((sum, s) => sum + s.costUsd, 0));
|
||||||
let totalTokens = $derived(agentSessions.reduce((sum, s) => sum + s.inputTokens + s.outputTokens, 0));
|
let totalTokens = $derived(agentSessions.reduce((sum, s) => sum + s.inputTokens + s.outputTokens, 0));
|
||||||
let terminalCount = $derived(panes.filter(p => p.type === 'terminal').length);
|
let projectCount = $derived(enabledProjects.length);
|
||||||
let agentCount = $derived(panes.filter(p => p.type === 'agent').length);
|
let agentCount = $derived(agentSessions.length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<span class="item" title="Terminal panes">{terminalCount} terminals</span>
|
{#if activeGroup}
|
||||||
|
<span class="item group-name" title="Active group">{activeGroup.name}</span>
|
||||||
|
<span class="sep"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="item" title="Enabled projects">{projectCount} projects</span>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<span class="item" title="Agent panes">{agentCount} agents</span>
|
<span class="item" title="Agent sessions">{agentCount} agents</span>
|
||||||
{#if activeAgents > 0}
|
{#if activeAgents > 0}
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<span class="item active">
|
<span class="item active">
|
||||||
|
|
@ -34,24 +39,24 @@
|
||||||
<span class="item cost">${totalCost.toFixed(4)}</span>
|
<span class="item cost">${totalCost.toFixed(4)}</span>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="item version">BTerminal v2</span>
|
<span class="item version">BTerminal v3</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.status-bar {
|
.status-bar {
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
background: var(--bg-secondary);
|
background: var(--ctp-mantle);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--ctp-surface0);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--ctp-overlay1);
|
||||||
font-family: var(--font-mono);
|
font-family: 'JetBrains Mono', monospace;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left, .right {
|
.left, .right {
|
||||||
|
|
@ -63,7 +68,7 @@
|
||||||
.sep {
|
.sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background: var(--border);
|
background: var(--ctp-surface1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
|
|
@ -72,6 +77,11 @@
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
color: var(--ctp-blue);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
color: var(--ctp-blue);
|
color: var(--ctp-blue);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { ProjectConfig } from '../../types/groups';
|
import type { ProjectConfig } from '../../types/groups';
|
||||||
import {
|
import {
|
||||||
loadProjectAgentState,
|
loadProjectAgentState,
|
||||||
saveProjectAgentState,
|
|
||||||
loadAgentMessages,
|
loadAgentMessages,
|
||||||
saveAgentMessages,
|
|
||||||
type ProjectAgentState,
|
type ProjectAgentState,
|
||||||
type AgentMessageRecord,
|
type AgentMessageRecord,
|
||||||
} from '../../adapters/groups-bridge';
|
} from '../../adapters/groups-bridge';
|
||||||
|
import { registerSessionProject } from '../../agent-dispatcher';
|
||||||
|
import {
|
||||||
|
createAgentSession,
|
||||||
|
appendAgentMessages,
|
||||||
|
updateAgentCost,
|
||||||
|
updateAgentStatus,
|
||||||
|
setAgentSdkSessionId,
|
||||||
|
getAgentSession,
|
||||||
|
} from '../../stores/agents.svelte';
|
||||||
|
import type { AgentMessage } from '../../adapters/sdk-messages';
|
||||||
import AgentPane from '../Agent/AgentPane.svelte';
|
import AgentPane from '../Agent/AgentPane.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -18,11 +25,10 @@
|
||||||
|
|
||||||
let { project, onsessionid }: Props = $props();
|
let { project, onsessionid }: Props = $props();
|
||||||
|
|
||||||
// Per-project session ID (stable across renders, changes with project)
|
|
||||||
let sessionId = $state(crypto.randomUUID());
|
let sessionId = $state(crypto.randomUUID());
|
||||||
let lastState = $state<ProjectAgentState | null>(null);
|
let lastState = $state<ProjectAgentState | null>(null);
|
||||||
let resumeSessionId = $state<string | undefined>(undefined);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let hasRestoredHistory = $state(false);
|
||||||
|
|
||||||
// Load previous session state when project changes
|
// Load previous session state when project changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -32,14 +38,20 @@
|
||||||
|
|
||||||
async function loadPreviousState(projectId: string) {
|
async function loadPreviousState(projectId: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
hasRestoredHistory = false;
|
||||||
try {
|
try {
|
||||||
const state = await loadProjectAgentState(projectId);
|
const state = await loadProjectAgentState(projectId);
|
||||||
lastState = state;
|
lastState = state;
|
||||||
if (state?.sdk_session_id) {
|
if (state?.last_session_id) {
|
||||||
resumeSessionId = state.sdk_session_id;
|
|
||||||
sessionId = state.last_session_id;
|
sessionId = state.last_session_id;
|
||||||
|
|
||||||
|
// Restore cached messages into the agent store
|
||||||
|
const records = await loadAgentMessages(projectId);
|
||||||
|
if (records.length > 0) {
|
||||||
|
restoreMessagesFromRecords(sessionId, state, records);
|
||||||
|
hasRestoredHistory = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
resumeSessionId = undefined;
|
|
||||||
sessionId = crypto.randomUUID();
|
sessionId = crypto.randomUUID();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -47,9 +59,45 @@
|
||||||
sessionId = crypto.randomUUID();
|
sessionId = crypto.randomUUID();
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
// Register session -> project mapping for persistence
|
||||||
|
registerSessionProject(sessionId, project.id);
|
||||||
onsessionid?.(sessionId);
|
onsessionid?.(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreMessagesFromRecords(
|
||||||
|
sid: string,
|
||||||
|
state: ProjectAgentState,
|
||||||
|
records: AgentMessageRecord[],
|
||||||
|
) {
|
||||||
|
// Don't re-create if already exists
|
||||||
|
if (getAgentSession(sid)) return;
|
||||||
|
|
||||||
|
createAgentSession(sid, state.last_prompt ?? '');
|
||||||
|
if (state.sdk_session_id) {
|
||||||
|
setAgentSdkSessionId(sid, state.sdk_session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert records back to AgentMessage format
|
||||||
|
const messages: AgentMessage[] = records.map(r => ({
|
||||||
|
id: `restored-${r.id}`,
|
||||||
|
type: r.message_type as AgentMessage['type'],
|
||||||
|
content: JSON.parse(r.content),
|
||||||
|
parentId: r.parent_id ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
appendAgentMessages(sid, messages);
|
||||||
|
updateAgentCost(sid, {
|
||||||
|
costUsd: state.cost_usd,
|
||||||
|
inputTokens: state.input_tokens,
|
||||||
|
outputTokens: state.output_tokens,
|
||||||
|
numTurns: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark as done (it's a restored completed session)
|
||||||
|
updateAgentStatus(sid, state.status === 'error' ? 'error' : 'done');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="claude-session">
|
<div class="claude-session">
|
||||||
|
|
@ -69,6 +117,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state {
|
.loading-state {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,10 @@ export function getTotalCost(id: string): { costUsd: number; inputTokens: number
|
||||||
return { costUsd, inputTokens, outputTokens };
|
return { costUsd, inputTokens, outputTokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearAllAgentSessions(): void {
|
||||||
|
sessions = [];
|
||||||
|
}
|
||||||
|
|
||||||
export function removeAgentSession(id: string): void {
|
export function removeAgentSession(id: string): void {
|
||||||
// Also remove from parent's childSessionIds
|
// Also remove from parent's childSessionIds
|
||||||
const session = sessions.find(s => s.id === id);
|
const session = sessions.find(s => s.id === id);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
|
||||||
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
|
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
|
||||||
|
import { clearAllAgentSessions } from '../stores/agents.svelte';
|
||||||
|
|
||||||
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
|
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
|
||||||
|
|
||||||
|
|
@ -66,8 +67,9 @@ export function setActiveProject(projectId: string | null): void {
|
||||||
export async function switchGroup(groupId: string): Promise<void> {
|
export async function switchGroup(groupId: string): Promise<void> {
|
||||||
if (groupId === activeGroupId) return;
|
if (groupId === activeGroupId) return;
|
||||||
|
|
||||||
// Clear terminal tabs for the old group
|
// Teardown: clear terminal tabs and agent sessions for the old group
|
||||||
projectTerminals = new Map();
|
projectTerminals = new Map();
|
||||||
|
clearAllAgentSessions();
|
||||||
|
|
||||||
activeGroupId = groupId;
|
activeGroupId = groupId;
|
||||||
activeProjectId = null;
|
activeProjectId = null;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue