mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 06:02:42 +00:00
feat(coding-agent): implement /tree command for session tree navigation
- Add TreeSelectorComponent with ASCII tree visualization - Add AgentSession.navigateTree() for switching branches - Add session_before_tree/session_tree hook events - Add SessionManager.resetLeaf() for navigating to root - Change leafId from string to string|null for consistency with parentId - Support optional branch summarization when switching - Update buildSessionContext() to handle null leafId - Add /tree to slash commands in interactive mode
This commit is contained in:
parent
256761e410
commit
4958271dd3
9 changed files with 893 additions and 443 deletions
|
|
@ -34,12 +34,14 @@ import type {
|
|||
SessionBeforeCompactResult,
|
||||
SessionBeforeNewResult,
|
||||
SessionBeforeSwitchResult,
|
||||
SessionBeforeTreeResult,
|
||||
TreePreparation,
|
||||
TurnEndEvent,
|
||||
TurnStartEvent,
|
||||
} from "./hooks/index.js";
|
||||
import type { BashExecutionMessage, HookMessage } from "./messages.js";
|
||||
import type { ModelRegistry } from "./model-registry.js";
|
||||
import type { CompactionEntry, SessionManager } from "./session-manager.js";
|
||||
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.js";
|
||||
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
|
||||
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
|
||||
|
||||
|
|
@ -1554,6 +1556,261 @@ export class AgentSession {
|
|||
return { selectedText, cancelled: false };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tree Navigation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Navigate to a different node in the session tree.
|
||||
* Unlike branch() which creates a new session file, this stays in the same file.
|
||||
*
|
||||
* @param targetId The entry ID to navigate to
|
||||
* @param options.summarize Whether user wants to summarize abandoned branch
|
||||
* @param options.customInstructions Custom instructions for summarizer
|
||||
* @returns Result with editorText (if user message) and cancelled status
|
||||
*/
|
||||
async navigateTree(
|
||||
targetId: string,
|
||||
options: { summarize?: boolean; customInstructions?: string } = {},
|
||||
): Promise<{ editorText?: string; cancelled: boolean }> {
|
||||
const oldLeafId = this.sessionManager.getLeafUuid();
|
||||
|
||||
// No-op if already at target
|
||||
if (targetId === oldLeafId) {
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
// Model required for summarization
|
||||
if (options.summarize && !this.model) {
|
||||
throw new Error("No model available for summarization");
|
||||
}
|
||||
|
||||
const targetEntry = this.sessionManager.getEntry(targetId);
|
||||
if (!targetEntry) {
|
||||
throw new Error(`Entry ${targetId} not found`);
|
||||
}
|
||||
|
||||
// Find common ancestor (if oldLeafId is null, there's no old path)
|
||||
const oldPath = oldLeafId ? new Set(this.sessionManager.getPath(oldLeafId).map((e) => e.id)) : new Set<string>();
|
||||
const targetPath = this.sessionManager.getPath(targetId);
|
||||
let commonAncestorId: string | null = null;
|
||||
for (const entry of targetPath) {
|
||||
if (oldPath.has(entry.id)) {
|
||||
commonAncestorId = entry.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect entries to summarize (old leaf back to common ancestor, stop at compaction)
|
||||
const entriesToSummarize: SessionEntry[] = [];
|
||||
if (options.summarize && oldLeafId) {
|
||||
let current: string | null = oldLeafId;
|
||||
while (current && current !== commonAncestorId) {
|
||||
const entry = this.sessionManager.getEntry(current);
|
||||
if (!entry) break;
|
||||
if (entry.type === "compaction") break;
|
||||
entriesToSummarize.push(entry);
|
||||
current = entry.parentId;
|
||||
}
|
||||
entriesToSummarize.reverse(); // Chronological order
|
||||
}
|
||||
|
||||
// Prepare event data
|
||||
const preparation: TreePreparation = {
|
||||
targetId,
|
||||
oldLeafId,
|
||||
commonAncestorId,
|
||||
entriesToSummarize,
|
||||
userWantsSummary: options.summarize ?? false,
|
||||
};
|
||||
|
||||
// Set up abort controller for summarization
|
||||
const abortController = new AbortController();
|
||||
let hookSummary: { summary: string; details?: unknown } | undefined;
|
||||
let fromHook = false;
|
||||
|
||||
// Emit session_before_tree event
|
||||
if (this._hookRunner?.hasHandlers("session_before_tree")) {
|
||||
const result = (await this._hookRunner.emit({
|
||||
type: "session_before_tree",
|
||||
preparation,
|
||||
model: this.model!, // Checked above if summarize is true
|
||||
signal: abortController.signal,
|
||||
})) as SessionBeforeTreeResult | undefined;
|
||||
|
||||
if (result?.cancel) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
if (result?.summary && options.summarize) {
|
||||
hookSummary = result.summary;
|
||||
fromHook = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Run default summarizer if needed
|
||||
let summaryText: string | undefined;
|
||||
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
||||
try {
|
||||
summaryText = await this._generateBranchSummary(
|
||||
entriesToSummarize,
|
||||
options.customInstructions,
|
||||
abortController.signal,
|
||||
);
|
||||
} catch {
|
||||
// Summarization failed - cancel navigation
|
||||
return { cancelled: true };
|
||||
}
|
||||
} else if (hookSummary) {
|
||||
summaryText = hookSummary.summary;
|
||||
}
|
||||
|
||||
// Determine the new leaf position based on target type
|
||||
let newLeafId: string | null;
|
||||
let editorText: string | undefined;
|
||||
|
||||
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
||||
// User message: leaf = parent (null if root), text goes to editor
|
||||
newLeafId = targetEntry.parentId;
|
||||
editorText = this._extractUserMessageText(targetEntry.message.content);
|
||||
} else if (targetEntry.type === "custom_message") {
|
||||
// Custom message: leaf = parent (null if root), text goes to editor
|
||||
newLeafId = targetEntry.parentId;
|
||||
editorText =
|
||||
typeof targetEntry.content === "string"
|
||||
? targetEntry.content
|
||||
: targetEntry.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
} else {
|
||||
// Non-user message: leaf = selected node
|
||||
newLeafId = targetId;
|
||||
}
|
||||
|
||||
// Switch leaf (with or without summary)
|
||||
let summaryEntry: BranchSummaryEntry | undefined;
|
||||
if (newLeafId === null) {
|
||||
// Navigating to root user message - reset leaf to empty
|
||||
this.sessionManager.resetLeaf();
|
||||
} else if (summaryText) {
|
||||
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText);
|
||||
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
||||
} else {
|
||||
this.sessionManager.branch(newLeafId);
|
||||
}
|
||||
|
||||
// Update agent state
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
this.agent.replaceMessages(sessionContext.messages);
|
||||
|
||||
// Emit session_tree event
|
||||
if (this._hookRunner) {
|
||||
await this._hookRunner.emit({
|
||||
type: "session_tree",
|
||||
newLeafId: this.sessionManager.getLeafUuid(),
|
||||
oldLeafId,
|
||||
summaryEntry,
|
||||
fromHook: summaryText ? fromHook : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit to custom tools
|
||||
await this._emitToolSessionEvent("tree", this.sessionFile);
|
||||
|
||||
return { editorText, cancelled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary of abandoned branch entries.
|
||||
*/
|
||||
private async _generateBranchSummary(
|
||||
entries: SessionEntry[],
|
||||
customInstructions: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
// Convert entries to messages for summarization
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "message") {
|
||||
const text = this._extractMessageText(entry.message);
|
||||
if (text) {
|
||||
messages.push({ role: entry.message.role, content: text });
|
||||
}
|
||||
} else if (entry.type === "custom_message") {
|
||||
const text =
|
||||
typeof entry.content === "string"
|
||||
? entry.content
|
||||
: entry.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (text) {
|
||||
messages.push({ role: "user", content: text });
|
||||
}
|
||||
} else if (entry.type === "branch_summary") {
|
||||
messages.push({ role: "system", content: `[Previous branch summary: ${entry.summary}]` });
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return "No content to summarize";
|
||||
}
|
||||
|
||||
// Build prompt for summarization
|
||||
const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n");
|
||||
const instructions = customInstructions
|
||||
? `${customInstructions}\n\n`
|
||||
: "Summarize this conversation branch concisely, capturing key decisions, actions taken, and outcomes.\n\n";
|
||||
|
||||
const prompt = `${instructions}Conversation:\n${conversationText}`;
|
||||
|
||||
// Get API key for current model (model is checked in navigateTree before calling this)
|
||||
const model = this.model!;
|
||||
const apiKey = await this._modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for ${model.provider}`);
|
||||
}
|
||||
|
||||
// Call LLM for summarization
|
||||
const { complete } = await import("@mariozechner/pi-ai");
|
||||
const response = await complete(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ apiKey, signal, maxTokens: 1024 },
|
||||
);
|
||||
|
||||
const summary = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
return summary || "No summary generated";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from any message type.
|
||||
*/
|
||||
private _extractMessageText(message: any): string {
|
||||
if (!message.content) return "";
|
||||
if (typeof message.content === "string") return message.content;
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user messages from session for branch selector.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export interface SessionEvent {
|
|||
/** Previous session file path, or undefined for "start" and "new" */
|
||||
previousSessionFile: string | undefined;
|
||||
/** Reason for the session event */
|
||||
reason: "start" | "switch" | "branch" | "new";
|
||||
reason: "start" | "switch" | "branch" | "new" | "tree";
|
||||
}
|
||||
|
||||
/** Rendering options passed to renderResult */
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
HookUIContext,
|
||||
RegisteredCommand,
|
||||
SessionBeforeCompactResult,
|
||||
SessionBeforeTreeResult,
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
ToolResultEventResult,
|
||||
|
|
@ -231,12 +232,18 @@ export class HookRunner {
|
|||
*/
|
||||
private isSessionBeforeEvent(
|
||||
type: string,
|
||||
): type is "session_before_switch" | "session_before_new" | "session_before_branch" | "session_before_compact" {
|
||||
): type is
|
||||
| "session_before_switch"
|
||||
| "session_before_new"
|
||||
| "session_before_branch"
|
||||
| "session_before_compact"
|
||||
| "session_before_tree" {
|
||||
return (
|
||||
type === "session_before_switch" ||
|
||||
type === "session_before_new" ||
|
||||
type === "session_before_branch" ||
|
||||
type === "session_before_compact"
|
||||
type === "session_before_compact" ||
|
||||
type === "session_before_tree"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -244,9 +251,11 @@ export class HookRunner {
|
|||
* Emit an event to all hooks.
|
||||
* Returns the result from session before_* / tool_result events (if any handler returns one).
|
||||
*/
|
||||
async emit(event: HookEvent): Promise<SessionBeforeCompactResult | ToolResultEventResult | undefined> {
|
||||
async emit(
|
||||
event: HookEvent,
|
||||
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let result: SessionBeforeCompactResult | ToolResultEventResult | undefined;
|
||||
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
|
||||
|
||||
for (const hook of this.hooks) {
|
||||
const handlers = hook.handlers.get(event.type);
|
||||
|
|
@ -267,7 +276,7 @@ export class HookRunner {
|
|||
|
||||
// For session before_* events, capture the result (for cancellation)
|
||||
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
|
||||
result = handlerResult as SessionBeforeCompactResult;
|
||||
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
|
||||
// If cancelled, stop processing further hooks
|
||||
if (result.cancel) {
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js";
|
|||
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||
import type { HookMessage } from "../messages.js";
|
||||
import type { ModelRegistry } from "../model-registry.js";
|
||||
import type { CompactionEntry, SessionManager } from "../session-manager.js";
|
||||
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "../session-manager.js";
|
||||
|
||||
/**
|
||||
* Read-only view of SessionManager for hooks.
|
||||
|
|
@ -177,6 +177,44 @@ export interface SessionShutdownEvent {
|
|||
type: "session_shutdown";
|
||||
}
|
||||
|
||||
/** Preparation data for tree navigation (used by session_before_tree event) */
|
||||
export interface TreePreparation {
|
||||
/** Node being switched to */
|
||||
targetId: string;
|
||||
/** Current active leaf (being abandoned), null if no current position */
|
||||
oldLeafId: string | null;
|
||||
/** Common ancestor of target and old leaf, null if no common ancestor */
|
||||
commonAncestorId: string | null;
|
||||
/** Entries to summarize (old leaf back to common ancestor or compaction) */
|
||||
entriesToSummarize: SessionEntry[];
|
||||
/** Whether user chose to summarize */
|
||||
userWantsSummary: boolean;
|
||||
}
|
||||
|
||||
/** Fired before navigating to a different node in the session tree (can be cancelled) */
|
||||
export interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
/** Preparation data for the navigation */
|
||||
preparation: TreePreparation;
|
||||
/** Model to use for summarization (conversation model) */
|
||||
model: Model<any>;
|
||||
/** Abort signal - honors Escape during summarization */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Fired after navigating to a different node in the session tree */
|
||||
export interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
/** The new active leaf, null if navigated to before first entry */
|
||||
newLeafId: string | null;
|
||||
/** Previous active leaf, null if there was no position */
|
||||
oldLeafId: string | null;
|
||||
/** Branch summary entry if one was created */
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
/** Whether summary came from hook */
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
/** Union of all session event types */
|
||||
export type SessionEvent =
|
||||
| SessionStartEvent
|
||||
|
|
@ -188,7 +226,9 @@ export type SessionEvent =
|
|||
| SessionBranchEvent
|
||||
| SessionBeforeCompactEvent
|
||||
| SessionCompactEvent
|
||||
| SessionShutdownEvent;
|
||||
| SessionShutdownEvent
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent;
|
||||
|
||||
/**
|
||||
* Event data for context event.
|
||||
|
|
@ -466,6 +506,20 @@ export interface SessionBeforeCompactResult {
|
|||
compaction?: CompactionResult;
|
||||
}
|
||||
|
||||
/** Return type for session_before_tree handlers */
|
||||
export interface SessionBeforeTreeResult {
|
||||
/** If true, cancel the navigation entirely */
|
||||
cancel?: boolean;
|
||||
/**
|
||||
* Custom summary (skips default summarizer).
|
||||
* Only used if preparation.userWantsSummary is true.
|
||||
*/
|
||||
summary?: {
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook API
|
||||
// ============================================================================
|
||||
|
|
@ -539,6 +593,8 @@ export interface HookAPI {
|
|||
): void;
|
||||
on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void;
|
||||
on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void;
|
||||
on(event: "session_before_tree", handler: HookHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
|
||||
on(event: "session_tree", handler: HookHandler<SessionTreeEvent>): void;
|
||||
|
||||
// Context and agent events
|
||||
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void;
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
|
|||
*/
|
||||
export function buildSessionContext(
|
||||
entries: SessionEntry[],
|
||||
leafId?: string,
|
||||
leafId?: string | null,
|
||||
byId?: Map<string, SessionEntry>,
|
||||
): SessionContext {
|
||||
// Build uuid index if not available
|
||||
|
|
@ -265,11 +265,15 @@ export function buildSessionContext(
|
|||
|
||||
// Find leaf
|
||||
let leaf: SessionEntry | undefined;
|
||||
if (leafId === null) {
|
||||
// Explicitly null - return no messages (navigated to before first entry)
|
||||
return { messages: [], thinkingLevel: "off", model: null };
|
||||
}
|
||||
if (leafId) {
|
||||
leaf = byId.get(leafId);
|
||||
}
|
||||
if (!leaf) {
|
||||
// Fallback to last entry
|
||||
// Fallback to last entry (when leafId is undefined)
|
||||
leaf = entries[entries.length - 1];
|
||||
}
|
||||
|
||||
|
|
@ -448,7 +452,7 @@ export class SessionManager {
|
|||
private fileEntries: FileEntry[] = [];
|
||||
private byId: Map<string, SessionEntry> = new Map();
|
||||
private labelsById: Map<string, string> = new Map();
|
||||
private leafId: string = "";
|
||||
private leafId: string | null = null;
|
||||
|
||||
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
|
||||
this.cwd = cwd;
|
||||
|
|
@ -496,7 +500,7 @@ export class SessionManager {
|
|||
};
|
||||
this.fileEntries = [header];
|
||||
this.byId.clear();
|
||||
this.leafId = "";
|
||||
this.leafId = null;
|
||||
this.flushed = false;
|
||||
// Only generate filename if persisting and not already set (e.g., via --session flag)
|
||||
if (this.persist && !this.sessionFile) {
|
||||
|
|
@ -509,7 +513,7 @@ export class SessionManager {
|
|||
private _buildIndex(): void {
|
||||
this.byId.clear();
|
||||
this.labelsById.clear();
|
||||
this.leafId = "";
|
||||
this.leafId = null;
|
||||
for (const entry of this.fileEntries) {
|
||||
if (entry.type === "session") continue;
|
||||
this.byId.set(entry.id, entry);
|
||||
|
|
@ -583,7 +587,7 @@ export class SessionManager {
|
|||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId || null,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
|
|
@ -596,7 +600,7 @@ export class SessionManager {
|
|||
const entry: ThinkingLevelChangeEntry = {
|
||||
type: "thinking_level_change",
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId || null,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
};
|
||||
|
|
@ -609,7 +613,7 @@ export class SessionManager {
|
|||
const entry: ModelChangeEntry = {
|
||||
type: "model_change",
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId || null,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
|
|
@ -623,7 +627,7 @@ export class SessionManager {
|
|||
const entry: CompactionEntry<T> = {
|
||||
type: "compaction",
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId || null,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
|
|
@ -641,7 +645,7 @@ export class SessionManager {
|
|||
customType,
|
||||
data,
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId || null,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
this._appendEntry(entry);
|
||||
|
|
@ -669,7 +673,7 @@ export class SessionManager {
|
|||
display,
|
||||
details,
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId || null,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
this._appendEntry(entry);
|
||||
|
|
@ -680,12 +684,12 @@ export class SessionManager {
|
|||
// Tree Traversal
|
||||
// =========================================================================
|
||||
|
||||
getLeafUuid(): string {
|
||||
getLeafUuid(): string | null {
|
||||
return this.leafId;
|
||||
}
|
||||
|
||||
getLeafEntry(): SessionEntry | undefined {
|
||||
return this.byId.get(this.leafId);
|
||||
return this.leafId ? this.byId.get(this.leafId) : undefined;
|
||||
}
|
||||
|
||||
getEntry(id: string): SessionEntry | undefined {
|
||||
|
|
@ -711,7 +715,7 @@ export class SessionManager {
|
|||
const entry: LabelEntry = {
|
||||
type: "label",
|
||||
id: generateId(this.byId),
|
||||
parentId: this.leafId || null,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId,
|
||||
label,
|
||||
|
|
@ -732,7 +736,8 @@ export class SessionManager {
|
|||
*/
|
||||
getPath(fromId?: string): SessionEntry[] {
|
||||
const path: SessionEntry[] = [];
|
||||
let current = this.byId.get(fromId ?? this.leafId);
|
||||
const startId = fromId ?? this.leafId;
|
||||
let current = startId ? this.byId.get(startId) : undefined;
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
|
|
@ -797,6 +802,13 @@ export class SessionManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort children by timestamp (oldest first, newest at bottom)
|
||||
const sortChildren = (node: SessionTreeNode): void => {
|
||||
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
||||
node.children.forEach(sortChildren);
|
||||
};
|
||||
roots.forEach(sortChildren);
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
|
|
@ -817,6 +829,15 @@ export class SessionManager {
|
|||
this.leafId = branchFromId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the leaf pointer to null (before any entries).
|
||||
* The next appendXXX() call will create a new root entry (parentId = null).
|
||||
* Use this when navigating to re-edit the first user message.
|
||||
*/
|
||||
resetLeaf(): void {
|
||||
this.leafId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new branch with a summary of the abandoned path.
|
||||
* Same as branch(), but also appends a branch_summary entry that captures
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue