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;
|
||||
maxTurns?: number;
|
||||
maxBudgetUsd?: number;
|
||||
/** @deprecated Use resumeMode='resume' + resumeSessionId instead. */
|
||||
resumeSessionId?: string;
|
||||
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
|
||||
resumeMode?: 'new' | 'continue' | 'resume';
|
||||
permissionMode?: string;
|
||||
settingSources?: string[];
|
||||
systemPrompt?: string;
|
||||
|
|
@ -74,7 +77,7 @@ async function handleMessage(msg: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
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)) {
|
||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||
|
|
@ -112,6 +115,20 @@ async function handleQuery(msg: QueryMessage) {
|
|||
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({
|
||||
prompt,
|
||||
options: {
|
||||
|
|
@ -121,7 +138,8 @@ async function handleQuery(msg: QueryMessage) {
|
|||
env: cleanEnv,
|
||||
maxTurns: maxTurns ?? undefined,
|
||||
maxBudgetUsd: maxBudgetUsd ?? undefined,
|
||||
resume: resumeSessionId ?? undefined,
|
||||
resume: resumeOpt,
|
||||
continue: continueOpt,
|
||||
allowedTools: [
|
||||
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||
'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 { SessionDb } from "../session-db.ts";
|
||||
import type { SearchDb } from "../search-db.ts";
|
||||
import { listClaudeSessions } from "../claude-sessions.ts";
|
||||
|
||||
export function createAgentHandlers(
|
||||
sidecarManager: SidecarManager,
|
||||
|
|
@ -13,13 +14,13 @@ export function createAgentHandlers(
|
|||
searchDb?: SearchDb,
|
||||
) {
|
||||
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 {
|
||||
const result = sidecarManager.startSession(
|
||||
sessionId as string,
|
||||
provider 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) {
|
||||
|
|
@ -169,5 +170,14 @@ export function createAgentHandlers(
|
|||
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>;
|
||||
additionalDirectories?: 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;
|
||||
|
|
@ -283,6 +287,12 @@ export class SidecarManager {
|
|||
if (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)}...`);
|
||||
this.writeToProcess(sessionId, queryMsg);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ChatInput from "./ChatInput.svelte";
|
||||
import SessionPicker from "./SessionPicker.svelte";
|
||||
import type { AgentMessage, AgentStatus } from "./agent-store.svelte.ts";
|
||||
import { t } from "./i18n.svelte.ts";
|
||||
|
||||
|
|
@ -14,6 +15,8 @@
|
|||
profile?: string;
|
||||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
projectId?: string;
|
||||
cwd?: string;
|
||||
onSend?: (text: string) => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
|
@ -25,6 +28,8 @@
|
|||
tokens,
|
||||
model = "claude-opus-4-5",
|
||||
provider = "claude",
|
||||
projectId = "",
|
||||
cwd = "",
|
||||
contextPct = 0,
|
||||
onSend,
|
||||
onStop,
|
||||
|
|
@ -115,6 +120,14 @@
|
|||
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
||||
<span class="strip-sep" aria-hidden="true"></span>
|
||||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||
{#if projectId && cwd && provider === "claude"}
|
||||
<SessionPicker
|
||||
{projectId}
|
||||
{provider}
|
||||
{cwd}
|
||||
onNewSession={onSend}
|
||||
/>
|
||||
{/if}
|
||||
{#if status === "running" && onStop}
|
||||
<button
|
||||
class="strip-stop-btn"
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@
|
|||
costUsd={getAgentCost()}
|
||||
tokens={getAgentTokens()}
|
||||
model={getAgentModel()}
|
||||
projectId={id}
|
||||
{cwd}
|
||||
{provider}
|
||||
{profile}
|
||||
{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>;
|
||||
additionalDirectories?: 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) ────────────────────────────────────────
|
||||
|
|
@ -297,6 +312,8 @@ function ensureListeners() {
|
|||
scheduleCleanup(session.sessionId, session.projectId);
|
||||
// Fix #14 (Codex audit): Enforce max sessions per project on completion
|
||||
enforceMaxSessions(session.projectId);
|
||||
// Invalidate Claude session cache so picker refreshes
|
||||
invalidateSessionCache(session.projectId);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -567,6 +584,8 @@ async function _startAgentInner(
|
|||
permissionMode: permissionMode,
|
||||
claudeConfigDir: options.claudeConfigDir,
|
||||
extraEnv: validateExtraEnv(options.extraEnv),
|
||||
resumeMode: options.resumeMode,
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
});
|
||||
|
||||
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
|
||||
// initialized yet (setAppRpc runs in main.ts after module imports resolve).
|
||||
// Listeners are registered lazily on first startAgent/getSession/sendPrompt call.
|
||||
|
|
|
|||
|
|
@ -389,6 +389,10 @@ export type PtyRPCRequests = {
|
|||
extraEnv?: Record<string, string>;
|
||||
additionalDirectories?: 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 };
|
||||
};
|
||||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Register an agent in btmsg. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue