feat(electrobun): session continuity — Claude JSONL listing, resume/continue sidecar support, session picker UI
This commit is contained in:
parent
0e217b9dae
commit
485abb4774
9 changed files with 626 additions and 4 deletions
|
|
@ -41,7 +41,10 @@ interface QueryMessage {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
maxBudgetUsd?: number;
|
maxBudgetUsd?: number;
|
||||||
|
/** @deprecated Use resumeMode='resume' + resumeSessionId instead. */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
|
||||||
|
resumeMode?: 'new' | 'continue' | 'resume';
|
||||||
permissionMode?: string;
|
permissionMode?: string;
|
||||||
settingSources?: string[];
|
settingSources?: string[];
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
|
@ -74,7 +77,7 @@ async function handleMessage(msg: Record<string, unknown>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuery(msg: QueryMessage) {
|
async function handleQuery(msg: QueryMessage) {
|
||||||
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
|
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, resumeMode, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
|
||||||
|
|
||||||
if (sessions.has(sessionId)) {
|
if (sessions.has(sessionId)) {
|
||||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||||
|
|
@ -112,6 +115,20 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build resume/continue options based on resumeMode
|
||||||
|
let resumeOpt: string | undefined;
|
||||||
|
let continueOpt: boolean | undefined;
|
||||||
|
if (resumeMode === 'continue') {
|
||||||
|
continueOpt = true;
|
||||||
|
log(`Session ${sessionId}: continuing most recent session`);
|
||||||
|
} else if (resumeMode === 'resume' && resumeSessionId) {
|
||||||
|
resumeOpt = resumeSessionId;
|
||||||
|
log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`);
|
||||||
|
} else if (resumeSessionId && !resumeMode) {
|
||||||
|
// Legacy: direct resumeSessionId without resumeMode
|
||||||
|
resumeOpt = resumeSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt,
|
prompt,
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -121,7 +138,8 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
env: cleanEnv,
|
env: cleanEnv,
|
||||||
maxTurns: maxTurns ?? undefined,
|
maxTurns: maxTurns ?? undefined,
|
||||||
maxBudgetUsd: maxBudgetUsd ?? undefined,
|
maxBudgetUsd: maxBudgetUsd ?? undefined,
|
||||||
resume: resumeSessionId ?? undefined,
|
resume: resumeOpt,
|
||||||
|
continue: continueOpt,
|
||||||
allowedTools: [
|
allowedTools: [
|
||||||
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||||
'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit',
|
'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit',
|
||||||
|
|
|
||||||
129
ui-electrobun/src/bun/claude-sessions.ts
Normal file
129
ui-electrobun/src/bun/claude-sessions.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
/**
|
||||||
|
* Claude session listing — reads Claude SDK session files from disk.
|
||||||
|
*
|
||||||
|
* Sessions stored as JSONL at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
|
||||||
|
* where <encoded-cwd> = absolute path with non-alphanumeric chars replaced by '-'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from "path";
|
||||||
|
import { homedir } from "os";
|
||||||
|
import { readdirSync, readFileSync, statSync } from "fs";
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ClaudeSessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
summary: string;
|
||||||
|
lastModified: number;
|
||||||
|
fileSize: number;
|
||||||
|
firstPrompt: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Implementation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function encodeCwd(cwd: string): string {
|
||||||
|
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Claude sessions for a project CWD.
|
||||||
|
* Reads the first 5 lines of each .jsonl file to extract metadata.
|
||||||
|
* Returns sessions sorted by lastModified descending.
|
||||||
|
*/
|
||||||
|
export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] {
|
||||||
|
const encoded = encodeCwd(cwd);
|
||||||
|
const sessionsDir = join(homedir(), ".claude", "projects", encoded);
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(sessionsDir);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// ENOENT or permission error — no sessions yet
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||||
|
console.warn("[claude-sessions] readdir error:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
|
||||||
|
const results: ClaudeSessionInfo[] = [];
|
||||||
|
|
||||||
|
for (const file of jsonlFiles) {
|
||||||
|
try {
|
||||||
|
const filePath = join(sessionsDir, file);
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
const sessionId = file.replace(/\.jsonl$/, "");
|
||||||
|
|
||||||
|
// Read first 5 lines for metadata extraction
|
||||||
|
const content = readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split("\n").slice(0, 5);
|
||||||
|
|
||||||
|
let firstPrompt = "";
|
||||||
|
let model = "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
|
||||||
|
// Extract model from init/system messages
|
||||||
|
if (!model && parsed.model) {
|
||||||
|
model = String(parsed.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract first user prompt
|
||||||
|
if (!firstPrompt && parsed.role === "user") {
|
||||||
|
const content = parsed.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
firstPrompt = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
// Content blocks format
|
||||||
|
const textBlock = content.find(
|
||||||
|
(b: Record<string, unknown>) => b.type === "text",
|
||||||
|
);
|
||||||
|
if (textBlock?.text) firstPrompt = String(textBlock.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for message type wrappers
|
||||||
|
if (!firstPrompt && parsed.type === "human" && parsed.message?.content) {
|
||||||
|
const mc = parsed.message.content;
|
||||||
|
if (typeof mc === "string") {
|
||||||
|
firstPrompt = mc;
|
||||||
|
} else if (Array.isArray(mc)) {
|
||||||
|
const textBlock = mc.find(
|
||||||
|
(b: Record<string, unknown>) => b.type === "text",
|
||||||
|
);
|
||||||
|
if (textBlock?.text) firstPrompt = String(textBlock.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed JSONL lines
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate first prompt for display
|
||||||
|
if (firstPrompt.length > 120) {
|
||||||
|
firstPrompt = firstPrompt.slice(0, 117) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
sessionId,
|
||||||
|
summary: firstPrompt || "(no prompt found)",
|
||||||
|
lastModified: stat.mtimeMs,
|
||||||
|
fileSize: stat.size,
|
||||||
|
firstPrompt: firstPrompt || "",
|
||||||
|
model: model || "unknown",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip corrupt/unreadable files
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by lastModified descending (newest first)
|
||||||
|
results.sort((a, b) => b.lastModified - a.lastModified);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import type { SidecarManager } from "../sidecar-manager.ts";
|
import type { SidecarManager } from "../sidecar-manager.ts";
|
||||||
import type { SessionDb } from "../session-db.ts";
|
import type { SessionDb } from "../session-db.ts";
|
||||||
import type { SearchDb } from "../search-db.ts";
|
import type { SearchDb } from "../search-db.ts";
|
||||||
|
import { listClaudeSessions } from "../claude-sessions.ts";
|
||||||
|
|
||||||
export function createAgentHandlers(
|
export function createAgentHandlers(
|
||||||
sidecarManager: SidecarManager,
|
sidecarManager: SidecarManager,
|
||||||
|
|
@ -13,13 +14,13 @@ export function createAgentHandlers(
|
||||||
searchDb?: SearchDb,
|
searchDb?: SearchDb,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record<string, unknown>) => {
|
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId }: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
const result = sidecarManager.startSession(
|
const result = sidecarManager.startSession(
|
||||||
sessionId as string,
|
sessionId as string,
|
||||||
provider as string,
|
provider as string,
|
||||||
prompt as string,
|
prompt as string,
|
||||||
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record<string, unknown>,
|
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId } as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
|
@ -169,5 +170,14 @@ export function createAgentHandlers(
|
||||||
return { messages: [] };
|
return { messages: [] };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"session.listClaude": ({ cwd }: { cwd: string }) => {
|
||||||
|
try {
|
||||||
|
return { sessions: listClaudeSessions(cwd) };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[session.listClaude]", err);
|
||||||
|
return { sessions: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ export interface StartSessionOptions {
|
||||||
extraEnv?: Record<string, string>;
|
extraEnv?: Record<string, string>;
|
||||||
additionalDirectories?: string[];
|
additionalDirectories?: string[];
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
|
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
|
||||||
|
resumeMode?: "new" | "continue" | "resume";
|
||||||
|
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
|
||||||
|
resumeSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void;
|
type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void;
|
||||||
|
|
@ -283,6 +287,12 @@ export class SidecarManager {
|
||||||
if (options.worktreeName) {
|
if (options.worktreeName) {
|
||||||
queryMsg.worktreeName = options.worktreeName;
|
queryMsg.worktreeName = options.worktreeName;
|
||||||
}
|
}
|
||||||
|
if (options.resumeMode && options.resumeMode !== "new") {
|
||||||
|
queryMsg.resumeMode = options.resumeMode;
|
||||||
|
}
|
||||||
|
if (options.resumeSessionId) {
|
||||||
|
queryMsg.resumeSessionId = options.resumeSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
dbg(`Sending query: ${JSON.stringify(queryMsg).slice(0, 200)}...`);
|
dbg(`Sending query: ${JSON.stringify(queryMsg).slice(0, 200)}...`);
|
||||||
this.writeToProcess(sessionId, queryMsg);
|
this.writeToProcess(sessionId, queryMsg);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import ChatInput from "./ChatInput.svelte";
|
import ChatInput from "./ChatInput.svelte";
|
||||||
|
import SessionPicker from "./SessionPicker.svelte";
|
||||||
import type { AgentMessage, AgentStatus } from "./agent-store.svelte.ts";
|
import type { AgentMessage, AgentStatus } from "./agent-store.svelte.ts";
|
||||||
import { t } from "./i18n.svelte.ts";
|
import { t } from "./i18n.svelte.ts";
|
||||||
|
|
||||||
|
|
@ -14,6 +15,8 @@
|
||||||
profile?: string;
|
profile?: string;
|
||||||
contextPct?: number;
|
contextPct?: number;
|
||||||
burnRate?: number;
|
burnRate?: number;
|
||||||
|
projectId?: string;
|
||||||
|
cwd?: string;
|
||||||
onSend?: (text: string) => void;
|
onSend?: (text: string) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +28,8 @@
|
||||||
tokens,
|
tokens,
|
||||||
model = "claude-opus-4-5",
|
model = "claude-opus-4-5",
|
||||||
provider = "claude",
|
provider = "claude",
|
||||||
|
projectId = "",
|
||||||
|
cwd = "",
|
||||||
contextPct = 0,
|
contextPct = 0,
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
|
|
@ -115,6 +120,14 @@
|
||||||
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
||||||
<span class="strip-sep" aria-hidden="true"></span>
|
<span class="strip-sep" aria-hidden="true"></span>
|
||||||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||||
|
{#if projectId && cwd && provider === "claude"}
|
||||||
|
<SessionPicker
|
||||||
|
{projectId}
|
||||||
|
{provider}
|
||||||
|
{cwd}
|
||||||
|
onNewSession={onSend}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if status === "running" && onStop}
|
{#if status === "running" && onStop}
|
||||||
<button
|
<button
|
||||||
class="strip-stop-btn"
|
class="strip-stop-btn"
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,8 @@
|
||||||
costUsd={getAgentCost()}
|
costUsd={getAgentCost()}
|
||||||
tokens={getAgentTokens()}
|
tokens={getAgentTokens()}
|
||||||
model={getAgentModel()}
|
model={getAgentModel()}
|
||||||
|
projectId={id}
|
||||||
|
{cwd}
|
||||||
{provider}
|
{provider}
|
||||||
{profile}
|
{profile}
|
||||||
{contextPct}
|
{contextPct}
|
||||||
|
|
|
||||||
333
ui-electrobun/src/mainview/SessionPicker.svelte
Normal file
333
ui-electrobun/src/mainview/SessionPicker.svelte
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ClaudeSessionInfo } from './agent-store.svelte.ts';
|
||||||
|
import { listProjectSessions, resumeSession, continueLastSession, startAgent } from './agent-store.svelte.ts';
|
||||||
|
import { t } from './i18n.svelte.ts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
currentSessionId?: string;
|
||||||
|
onNewSession?: (prompt: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
projectId,
|
||||||
|
provider,
|
||||||
|
cwd,
|
||||||
|
currentSessionId,
|
||||||
|
onNewSession,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let sessions = $state<ClaudeSessionInfo[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchSessions();
|
||||||
|
// Close on outside click
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (containerEl && !containerEl.contains(e.target as Node)) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchSessions() {
|
||||||
|
if (!cwd) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
sessions = await listProjectSessions(projectId, cwd);
|
||||||
|
} catch {
|
||||||
|
sessions = [];
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open;
|
||||||
|
if (open) fetchSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffH = (now - ms) / 3_600_000;
|
||||||
|
if (diffH < 1) return `${Math.max(1, Math.round(diffH * 60))}m ago`;
|
||||||
|
if (diffH < 24) return `${Math.round(diffH)}h ago`;
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContinue() {
|
||||||
|
open = false;
|
||||||
|
await continueLastSession(projectId, provider, '', cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResume(sdkSessionId: string) {
|
||||||
|
open = false;
|
||||||
|
await resumeSession(projectId, provider, sdkSessionId, '', cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNew() {
|
||||||
|
open = false;
|
||||||
|
onNewSession?.('');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="session-picker" bind:this={containerEl}>
|
||||||
|
<button
|
||||||
|
class="picker-trigger"
|
||||||
|
onclick={toggle}
|
||||||
|
title="Session history"
|
||||||
|
aria-label="Session history"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 3.5a.5.5 0 0 0-1 0V8a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 7.71V3.5z"/>
|
||||||
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="picker-dropdown" style:display={open ? 'flex' : 'none'}>
|
||||||
|
<div class="dropdown-header">
|
||||||
|
<span class="dropdown-title">Sessions</span>
|
||||||
|
<button class="new-btn" onclick={handleNew}>
|
||||||
|
+ New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if sessions.length > 0}
|
||||||
|
<button class="continue-btn" onclick={handleContinue}>
|
||||||
|
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="continue-icon">
|
||||||
|
<path d="M4 2l8 6-8 6V2z"/>
|
||||||
|
</svg>
|
||||||
|
Continue last session
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
|
<div class="session-list">
|
||||||
|
{#if loading}
|
||||||
|
<div class="session-empty">Loading...</div>
|
||||||
|
{:else if sessions.length === 0}
|
||||||
|
<div class="session-empty">No previous sessions</div>
|
||||||
|
{:else}
|
||||||
|
{#each sessions as s (s.sessionId)}
|
||||||
|
<button
|
||||||
|
class="session-row"
|
||||||
|
class:active={currentSessionId === s.sessionId}
|
||||||
|
onclick={() => handleResume(s.sessionId)}
|
||||||
|
>
|
||||||
|
<div class="session-prompt">{truncate(s.firstPrompt || s.summary, 60)}</div>
|
||||||
|
<div class="session-meta">
|
||||||
|
<span class="meta-date">{fmtDate(s.lastModified)}</span>
|
||||||
|
<span class="meta-sep" aria-hidden="true"></span>
|
||||||
|
<span class="meta-size">{fmtSize(s.fileSize)}</span>
|
||||||
|
<span class="meta-sep" aria-hidden="true"></span>
|
||||||
|
<span class="meta-model">{s.model}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.session-picker {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-trigger:hover {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-trigger svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 18rem;
|
||||||
|
max-height: 20rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-title {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ctp-blue);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ctp-green);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--ctp-green) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-icon {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list::-webkit-scrollbar {
|
||||||
|
width: 0.25rem;
|
||||||
|
}
|
||||||
|
.session-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.session-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-empty {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row:hover {
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row.active {
|
||||||
|
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-prompt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-model {
|
||||||
|
color: var(--ctp-overlay0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -47,6 +47,21 @@ interface StartOptions {
|
||||||
extraEnv?: Record<string, string>;
|
extraEnv?: Record<string, string>;
|
||||||
additionalDirectories?: string[];
|
additionalDirectories?: string[];
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
|
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
|
||||||
|
resumeMode?: 'new' | 'continue' | 'resume';
|
||||||
|
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
|
||||||
|
resumeSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Claude session listing types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ClaudeSessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
summary: string;
|
||||||
|
lastModified: number;
|
||||||
|
fileSize: number;
|
||||||
|
firstPrompt: string;
|
||||||
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toast callback (set by App.svelte) ────────────────────────────────────────
|
// ── Toast callback (set by App.svelte) ────────────────────────────────────────
|
||||||
|
|
@ -297,6 +312,8 @@ function ensureListeners() {
|
||||||
scheduleCleanup(session.sessionId, session.projectId);
|
scheduleCleanup(session.sessionId, session.projectId);
|
||||||
// Fix #14 (Codex audit): Enforce max sessions per project on completion
|
// Fix #14 (Codex audit): Enforce max sessions per project on completion
|
||||||
enforceMaxSessions(session.projectId);
|
enforceMaxSessions(session.projectId);
|
||||||
|
// Invalidate Claude session cache so picker refreshes
|
||||||
|
invalidateSessionCache(session.projectId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -567,6 +584,8 @@ async function _startAgentInner(
|
||||||
permissionMode: permissionMode,
|
permissionMode: permissionMode,
|
||||||
claudeConfigDir: options.claudeConfigDir,
|
claudeConfigDir: options.claudeConfigDir,
|
||||||
extraEnv: validateExtraEnv(options.extraEnv),
|
extraEnv: validateExtraEnv(options.extraEnv),
|
||||||
|
resumeMode: options.resumeMode,
|
||||||
|
resumeSessionId: options.resumeSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|
@ -828,6 +847,75 @@ function enforceMaxSessions(projectId: string): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Session continuity API ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Claude session list cache per project (keyed by projectId)
|
||||||
|
const claudeSessionCache = new Map<string, { sessions: ClaudeSessionInfo[]; fetchedAt: number }>();
|
||||||
|
const CACHE_TTL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Claude SDK sessions from disk for a project CWD.
|
||||||
|
* Cached for 30 seconds; invalidated on agent completion.
|
||||||
|
*/
|
||||||
|
export async function listProjectSessions(projectId: string, cwd: string): Promise<ClaudeSessionInfo[]> {
|
||||||
|
const cached = claudeSessionCache.get(projectId);
|
||||||
|
if (cached && (Date.now() - cached.fetchedAt) < CACHE_TTL_MS) {
|
||||||
|
return cached.sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await appRpc.request['session.listClaude']({ cwd });
|
||||||
|
const sessions = (result?.sessions ?? []) as ClaudeSessionInfo[];
|
||||||
|
claudeSessionCache.set(projectId, { sessions, fetchedAt: Date.now() });
|
||||||
|
return sessions;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[listProjectSessions] error:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate the Claude session cache for a project. */
|
||||||
|
export function invalidateSessionCache(projectId: string): void {
|
||||||
|
claudeSessionCache.delete(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continue the most recent Claude session for a project.
|
||||||
|
* Uses SDK `continue: true` — picks up where the last session left off.
|
||||||
|
*/
|
||||||
|
export async function continueLastSession(
|
||||||
|
projectId: string,
|
||||||
|
provider: string,
|
||||||
|
prompt: string,
|
||||||
|
cwd: string,
|
||||||
|
options: Omit<StartOptions, 'resumeMode' | 'resumeSessionId' | 'cwd'> = {},
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
return startAgent(projectId, provider, prompt, {
|
||||||
|
...options,
|
||||||
|
cwd,
|
||||||
|
resumeMode: 'continue',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume a specific Claude session by its SDK session ID.
|
||||||
|
*/
|
||||||
|
export async function resumeSession(
|
||||||
|
projectId: string,
|
||||||
|
provider: string,
|
||||||
|
sdkSessionId: string,
|
||||||
|
prompt: string,
|
||||||
|
cwd: string,
|
||||||
|
options: Omit<StartOptions, 'resumeMode' | 'resumeSessionId' | 'cwd'> = {},
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
return startAgent(projectId, provider, prompt, {
|
||||||
|
...options,
|
||||||
|
cwd,
|
||||||
|
resumeMode: 'resume',
|
||||||
|
resumeSessionId: sdkSessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Do NOT call ensureListeners() at module load — appRpc may not be
|
// NOTE: Do NOT call ensureListeners() at module load — appRpc may not be
|
||||||
// initialized yet (setAppRpc runs in main.ts after module imports resolve).
|
// initialized yet (setAppRpc runs in main.ts after module imports resolve).
|
||||||
// Listeners are registered lazily on first startAgent/getSession/sendPrompt call.
|
// Listeners are registered lazily on first startAgent/getSession/sendPrompt call.
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,10 @@ export type PtyRPCRequests = {
|
||||||
extraEnv?: Record<string, string>;
|
extraEnv?: Record<string, string>;
|
||||||
additionalDirectories?: string[];
|
additionalDirectories?: string[];
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
|
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific session). */
|
||||||
|
resumeMode?: "new" | "continue" | "resume";
|
||||||
|
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
|
||||||
|
resumeSessionId?: string;
|
||||||
};
|
};
|
||||||
response: { ok: boolean; error?: string };
|
response: { ok: boolean; error?: string };
|
||||||
};
|
};
|
||||||
|
|
@ -477,6 +481,21 @@ export type PtyRPCRequests = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** List Claude SDK sessions from disk for a project CWD. */
|
||||||
|
"session.listClaude": {
|
||||||
|
params: { cwd: string };
|
||||||
|
response: {
|
||||||
|
sessions: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
summary: string;
|
||||||
|
lastModified: number;
|
||||||
|
fileSize: number;
|
||||||
|
firstPrompt: string;
|
||||||
|
model: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// ── btmsg RPC ──────────────────────────────────────────────────────────
|
// ── btmsg RPC ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Register an agent in btmsg. */
|
/** Register an agent in btmsg. */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue