rait-09_obsidian-agent-client/src/ui/SessionHistoryModal.tsx
RAIT-09 3b7191337d fix(text): keep truncateTitle output within stated maxLength
The previous implementation appended '...' AFTER slicing to maxLength,
so the returned string could exceed the stated 'maximum length' by 3
characters. This affected callers such as forkSession (title 'Fork:
<truncated>...' could reach 53 chars instead of the intended 50) and
saveSessionLocally.

Truncate to maxLength - ELLIPSIS.length and only then append the
ellipsis, so the final length is bounded by maxLength. Handle the
edge case maxLength <= ELLIPSIS.length by omitting the ellipsis.

Drop the duplicate truncateTitle defined locally in
SessionHistoryModal.tsx and import from utils/text; it shared the
same bug and now disappears from a single fix site.
2026-05-22 14:40:10 +09:00

728 lines
19 KiB
TypeScript

/**
* Session History Modal
*
* Contains the Obsidian Modal wrapper, the React content component,
* and the confirmation modal for session deletion.
*/
import { Modal, App, setIcon } from "obsidian";
import { EditTitleModal } from "./EditTitleModal";
import * as React from "react";
const { useState, useCallback } = React;
import { createRoot, Root } from "react-dom/client";
import type { SessionInfo } from "../types/session";
import { truncateTitle } from "../utils/text";
// ============================================================
// ConfirmDeleteModal (internal)
// ============================================================
/**
* Confirmation modal for session deletion.
*
* Displays session title and asks user to confirm deletion.
* Calls onConfirm callback only when user clicks Delete button.
*/
class ConfirmDeleteModal extends Modal {
private sessionTitle: string;
private onConfirm: () => void | Promise<void>;
constructor(
app: App,
sessionTitle: string,
onConfirm: () => void | Promise<void>,
) {
super(app);
this.sessionTitle = sessionTitle;
this.onConfirm = onConfirm;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
// Title
contentEl.createEl("h2", { text: "Delete session?" });
// Message
contentEl.createEl("p", {
text: `Are you sure you want to delete "${this.sessionTitle}"?`,
cls: "agent-client-confirm-delete-message",
});
contentEl.createEl("p", {
text: "This only removes the session from this plugin. The session data will remain on the agent side.",
cls: "agent-client-confirm-delete-warning",
});
// Buttons container
const buttonContainer = contentEl.createDiv({
cls: "agent-client-confirm-delete-buttons",
});
// Cancel button
const cancelButton = buttonContainer.createEl("button", {
text: "Cancel",
cls: "agent-client-confirm-delete-cancel",
});
cancelButton.addEventListener("click", () => {
this.close();
});
// Delete button
const deleteButton = buttonContainer.createEl("button", {
text: "Delete",
cls: "agent-client-confirm-delete-confirm mod-warning",
});
deleteButton.addEventListener("click", () => {
this.close();
void this.onConfirm();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// ============================================================
// SessionHistoryContent (internal)
// ============================================================
/**
* Props for SessionHistoryContent component.
*/
interface SessionHistoryContentProps {
/** Obsidian App instance (for creating modals) */
app: App;
/** List of sessions to display */
sessions: SessionInfo[];
/** Whether sessions are being fetched */
loading: boolean;
/** Error message if fetch fails */
error: string | null;
/** Whether there are more sessions to load */
hasMore: boolean;
/** Current working directory for filtering */
currentCwd: string;
// Capability flags (from useSessionHistory)
/** Whether session/list is supported (unstable) */
canList: boolean;
/** Whether session can be restored (load or resume supported) */
canRestore: boolean;
/** Whether session/fork is supported (unstable) */
canFork: boolean;
/** Whether using locally saved sessions (instead of agent session/list) */
isUsingLocalSessions: boolean;
/** Set of session IDs that have local data (for filtering) */
localSessionIds: Set<string>;
/** Whether the agent is ready (initialized) */
isAgentReady: boolean;
/** Whether debug mode is enabled (shows manual input form) */
debugMode: boolean;
/** Callback when a session is restored */
onRestoreSession: (sessionId: string, cwd: string) => Promise<void>;
/** Callback when a session is forked (create new branch) */
onForkSession: (sessionId: string, cwd: string) => Promise<void>;
/** Callback when a session is deleted */
onDeleteSession: (sessionId: string) => void | Promise<void>;
/** Callback when a session title is edited */
onEditTitle: (
sessionId: string,
newTitle: string,
sessionCwd: string,
) => void | Promise<void>;
/** Callback to load more sessions (pagination) */
onLoadMore: () => void;
/** Callback to fetch sessions with filter */
onFetchSessions: (cwd?: string) => void;
/** Callback to close the modal */
onClose: () => void;
}
/**
* Icon button component using Obsidian's setIcon.
*/
function IconButton({
iconName,
label,
className,
onClick,
}: {
iconName: string;
label: string;
className: string;
onClick: () => void;
}) {
const iconRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (iconRef.current) {
setIcon(iconRef.current, iconName);
}
}, [iconName]);
return (
<div
ref={iconRef}
className={className}
aria-label={label}
onClick={onClick}
/>
);
}
/**
* Format timestamp as relative time.
* Examples: "2 hours ago", "yesterday", "3 days ago"
*/
function formatRelativeTime(date: Date): string {
const now = Date.now();
const timestamp = date.getTime();
const diffMs = now - timestamp;
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMinutes < 1) {
return "just now";
} else if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
} else if (diffDays === 1) {
return "yesterday";
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
const month = date.toLocaleString("default", { month: "short" });
const day = date.getDate();
const year = date.getFullYear();
return `${month} ${day}, ${year}`;
}
}
/**
* Debug form for manual session input.
*/
function DebugForm({
currentCwd,
onRestoreSession,
onForkSession,
onClose,
}: {
currentCwd: string;
onRestoreSession: (sessionId: string, cwd: string) => Promise<void>;
onForkSession: (sessionId: string, cwd: string) => Promise<void>;
onClose: () => void;
}) {
const [sessionId, setSessionId] = useState("");
const [cwd, setCwd] = useState(currentCwd);
const handleRestore = useCallback(() => {
if (sessionId.trim()) {
onClose();
void onRestoreSession(sessionId.trim(), cwd.trim() || currentCwd);
}
}, [sessionId, cwd, currentCwd, onRestoreSession, onClose]);
const handleFork = useCallback(() => {
if (sessionId.trim()) {
onClose();
void onForkSession(sessionId.trim(), cwd.trim() || currentCwd);
}
}, [sessionId, cwd, currentCwd, onForkSession, onClose]);
return (
<div className="agent-client-session-history-debug">
<h3>Debug: Manual Session Input</h3>
<div className="agent-client-session-history-debug-group">
<label htmlFor="debug-session-id">Session ID:</label>
<input
id="debug-session-id"
type="text"
placeholder="Enter session ID..."
className="agent-client-session-history-debug-input"
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
/>
</div>
<div className="agent-client-session-history-debug-group">
<label htmlFor="debug-cwd">Working Directory (cwd):</label>
<input
id="debug-cwd"
type="text"
placeholder="Enter working directory..."
className="agent-client-session-history-debug-input"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
</div>
<div className="agent-client-session-history-debug-actions">
<button
className="agent-client-session-history-debug-button"
onClick={handleRestore}
>
Restore
</button>
<button
className="agent-client-session-history-debug-button"
onClick={handleFork}
>
Fork
</button>
</div>
<hr className="agent-client-session-history-debug-separator" />
</div>
);
}
/**
* Session list item component.
*/
function SessionItem({
session,
canRestore,
canFork,
currentCwd,
onRestoreSession,
onForkSession,
onDeleteSession,
onEditTitle,
onClose,
}: {
session: SessionInfo;
canRestore: boolean;
canFork: boolean;
currentCwd: string;
onRestoreSession: (sessionId: string, cwd: string) => Promise<void>;
onForkSession: (sessionId: string, cwd: string) => Promise<void>;
onDeleteSession: (sessionId: string) => void | Promise<void>;
onEditTitle: (sessionId: string) => void;
onClose: () => void;
}) {
const handleRestore = useCallback(() => {
onClose();
void onRestoreSession(session.sessionId, session.cwd);
}, [session, onRestoreSession, onClose]);
const handleFork = useCallback(() => {
onClose();
void onForkSession(session.sessionId, session.cwd);
}, [session, onForkSession, onClose]);
const handleDelete = useCallback(() => {
void onDeleteSession(session.sessionId);
}, [session.sessionId, onDeleteSession]);
const handleEditTitle = useCallback(() => {
onEditTitle(session.sessionId);
}, [session.sessionId, onEditTitle]);
return (
<div className="agent-client-session-history-item">
<div className="agent-client-session-history-item-content">
<div className="agent-client-session-history-item-title">
<span>
{truncateTitle(session.title ?? "Untitled Session")}
</span>
</div>
<div className="agent-client-session-history-item-metadata">
{session.updatedAt && (
<span className="agent-client-session-history-item-timestamp">
{formatRelativeTime(new Date(session.updatedAt))}
</span>
)}
{session.cwd !== currentCwd && (
<span
className="agent-client-session-history-item-cwd"
title={session.cwd}
>
{session.cwd}
</span>
)}
</div>
</div>
<div className="agent-client-session-history-item-actions">
<IconButton
iconName="pencil"
label="Edit session title"
className="agent-client-session-history-action-icon agent-client-session-history-edit-icon"
onClick={handleEditTitle}
/>
{canRestore && (
<IconButton
iconName="play"
label="Restore session"
className="agent-client-session-history-action-icon agent-client-session-history-restore-icon"
onClick={handleRestore}
/>
)}
{canFork && (
<IconButton
iconName="git-branch"
label="Fork session (create new branch)"
className="agent-client-session-history-action-icon agent-client-session-history-fork-icon"
onClick={handleFork}
/>
)}
<IconButton
iconName="trash-2"
label="Delete session"
className="agent-client-session-history-action-icon agent-client-session-history-delete-icon"
onClick={handleDelete}
/>
</div>
</div>
);
}
/**
* Session history content component.
*
* Renders the content of the session history modal including:
* - Debug form (when debug mode enabled)
* - Local sessions banner
* - Filter toggle (for agent session/list)
* - Session list with load/resume/fork actions
* - Pagination
*/
function SessionHistoryContent({
app,
sessions,
loading,
error,
hasMore,
currentCwd,
canList,
canRestore,
canFork,
isUsingLocalSessions,
localSessionIds,
isAgentReady,
debugMode,
onRestoreSession,
onForkSession,
onDeleteSession,
onEditTitle,
onLoadMore,
onFetchSessions,
onClose,
}: SessionHistoryContentProps) {
const [filterByCurrentVault, setFilterByCurrentVault] = useState(true);
const [hideNonLocalSessions, setHideNonLocalSessions] = useState(false);
const handleFilterChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
setFilterByCurrentVault(checked);
const cwd = checked ? currentCwd : undefined;
onFetchSessions(cwd);
},
[currentCwd, onFetchSessions],
);
const handleRetry = useCallback(() => {
const cwd = filterByCurrentVault ? currentCwd : undefined;
onFetchSessions(cwd);
}, [filterByCurrentVault, currentCwd, onFetchSessions]);
// Wrap onDeleteSession to show confirmation modal
const handleDeleteWithConfirmation = useCallback(
(sessionId: string) => {
const targetSession = sessions.find(
(s) => s.sessionId === sessionId,
);
const sessionTitle = targetSession?.title ?? "Untitled Session";
const confirmModal = new ConfirmDeleteModal(
app,
sessionTitle,
() => {
void onDeleteSession(sessionId);
},
);
confirmModal.open();
},
[app, sessions, onDeleteSession],
);
// Open edit title modal for a session
const handleEditWithModal = useCallback(
(sessionId: string) => {
const targetSession = sessions.find(
(s) => s.sessionId === sessionId,
);
const currentTitle = targetSession?.title ?? "Untitled Session";
const sessionCwd = targetSession?.cwd ?? currentCwd;
const modal = new EditTitleModal(app, currentTitle, (newTitle) => {
void onEditTitle(sessionId, newTitle, sessionCwd);
});
modal.open();
},
[app, sessions, currentCwd, onEditTitle],
);
// Filter sessions based on hideNonLocalSessions setting
// Only applies to agent session/list (not local sessions which are already filtered)
const filteredSessions = React.useMemo(() => {
if (isUsingLocalSessions || !hideNonLocalSessions) {
return sessions;
}
return sessions.filter((s) => localSessionIds.has(s.sessionId));
}, [sessions, isUsingLocalSessions, hideNonLocalSessions, localSessionIds]);
// Show preparing message if agent is not ready
if (!isAgentReady) {
return (
<div className="agent-client-session-history-loading">
<p>Preparing agent...</p>
</div>
);
}
// Check if any session operation is available
const canPerformAnyOperation = canRestore || canFork;
// Show local sessions list (always show for delete functionality)
// - If agent supports list: use agent's session/list
// - If agent doesn't support list OR doesn't support restoration: use locally saved sessions
const canShowList =
canList || isUsingLocalSessions || !canPerformAnyOperation;
return (
<>
{/* Debug form */}
{debugMode && (
<DebugForm
currentCwd={currentCwd}
onRestoreSession={onRestoreSession}
onForkSession={onForkSession}
onClose={onClose}
/>
)}
{/* Warning banner for agents that don't support restoration */}
{!canPerformAnyOperation && (
<div className="agent-client-session-history-warning-banner">
<p>This agent does not support session restoration.</p>
</div>
)}
{/* Local sessions banner */}
{(isUsingLocalSessions || !canPerformAnyOperation) && (
<div className="agent-client-session-history-local-banner">
<span>These sessions are saved in the plugin.</span>
</div>
)}
{/* No list capability message */}
{!canShowList && !debugMode && (
<div className="agent-client-session-history-empty">
<p className="agent-client-session-history-empty-text">
Session list is not available for this agent.
</p>
<p className="agent-client-session-history-empty-text">
Enable Debug Mode in settings to manually enter session
IDs.
</p>
</div>
)}
{canShowList && (
<>
{/* Filter toggles - only for agent session/list */}
{canList && !isUsingLocalSessions && (
<div className="agent-client-session-history-filter">
<label className="agent-client-session-history-filter-label">
<input
type="checkbox"
checked={filterByCurrentVault}
onChange={handleFilterChange}
/>
<span>Show current vault only</span>
</label>
<label className="agent-client-session-history-filter-label">
<input
type="checkbox"
checked={hideNonLocalSessions}
onChange={(e) =>
setHideNonLocalSessions(
e.target.checked,
)
}
/>
<span>Hide sessions without local data</span>
</label>
</div>
)}
{/* Error state */}
{error && (
<div className="agent-client-session-history-error">
<p className="agent-client-session-history-error-text">
{error}
</p>
<button
className="agent-client-session-history-retry-button"
onClick={handleRetry}
>
Retry
</button>
</div>
)}
{/* Loading state */}
{!error && loading && filteredSessions.length === 0 && (
<div className="agent-client-session-history-loading">
<p>Loading sessions...</p>
</div>
)}
{/* Empty state */}
{!error && !loading && filteredSessions.length === 0 && (
<div className="agent-client-session-history-empty">
<p className="agent-client-session-history-empty-text">
No previous sessions
</p>
</div>
)}
{/* Session list */}
{!error && filteredSessions.length > 0 && (
<div className="agent-client-session-history-list">
{filteredSessions.map((session) => (
<SessionItem
key={session.sessionId}
session={session}
canRestore={canRestore}
canFork={canFork}
currentCwd={currentCwd}
onRestoreSession={onRestoreSession}
onForkSession={onForkSession}
onDeleteSession={
handleDeleteWithConfirmation
}
onEditTitle={handleEditWithModal}
onClose={onClose}
/>
))}
</div>
)}
{/* Load more button */}
{!error && hasMore && (
<div className="agent-client-session-history-load-more">
<button
className="agent-client-session-history-load-more-button"
disabled={loading}
onClick={onLoadMore}
>
{loading ? "Loading..." : "Load more"}
</button>
</div>
)}
</>
)}
</>
);
}
// ============================================================
// SessionHistoryModal (exported)
// ============================================================
/**
* Props for SessionHistoryModal (same as SessionHistoryContentProps minus onClose and app).
*/
export type SessionHistoryModalProps = Omit<
SessionHistoryContentProps,
"onClose" | "app"
>;
/**
* Modal for displaying and selecting from session history.
*
* This is a thin wrapper around the SessionHistoryContent React component.
* It extends Obsidian's Modal class for proper modal behavior (backdrop,
* escape key handling, etc.) while delegating all UI rendering to React.
*/
export class SessionHistoryModal extends Modal {
private root: Root | null = null;
private props: SessionHistoryModalProps;
constructor(app: App, props: SessionHistoryModalProps) {
super(app);
this.props = props;
}
/**
* Update modal props and re-render the React component.
* Call this when session data changes.
*/
updateProps(props: SessionHistoryModalProps) {
this.props = props;
this.renderContent();
}
/**
* Called when modal is opened.
* Creates React root and renders the content.
*/
onOpen() {
const { contentEl } = this;
contentEl.empty();
// Add modal title
contentEl.createEl("h2", { text: "Session history" });
// Create container for React content
const reactContainer = contentEl.createDiv();
// Create React root and render
this.root = createRoot(reactContainer);
this.renderContent();
}
/**
* Render or re-render the React content.
*/
private renderContent() {
if (this.root) {
this.root.render(
React.createElement(SessionHistoryContent, {
...this.props,
app: this.app,
onClose: () => this.close(),
}),
);
}
}
/**
* Called when modal is closed.
* Unmounts React component and cleans up.
*/
onClose() {
if (this.root) {
this.root.unmount();
this.root = null;
}
const { contentEl } = this;
contentEl.empty();
}
}