feat: auto-delete old conversations, and stop a tool call that repeats (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-10-05 09:05:15 +00:00
parent 576c5946f7
commit 05fd9ff13b
25 changed files with 2372 additions and 59 deletions
+46 -6
View File
@@ -1,7 +1,8 @@
import { Action, ActionPanel, Icon, LaunchType, List, launchCommand } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { readAttachment } from "../attachments";
import { useChat } from "../hooks/useChat";
import { runAutoDelete } from "../cleanup";
import { useChat, type ToolActivity } from "../hooks/useChat";
import { useVisionSupport } from "../hooks/useVision";
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
import { getLamPreferences } from "../preferences";
@@ -61,6 +62,18 @@ const DEFAULT_IMAGE_PROMPT = "What is in this image?";
/** The reminder shown while a snip is attached but not yet sent. */
const PENDING_NOTE = "_Ask a question about it, or press Return to send it as it is._";
/**
* The line that shows what Lam is doing right now.
*
* A count on its own reads as progress, so the second time round says what it is:
* the same call, asked again. That is the visible half of the loop guard in
* `agent.ts`, and it is the only warning the user gets before a turn is cut short.
*/
function describeActivity(activity: ToolActivity): string {
if (activity.count <= 1) return `_${activity.label}…_`;
return `_${activity.label} · the same call again (${activity.count})_`;
}
export interface ChatViewProps {
/** Opens a specific conversation instead of resuming the latest one. */
initialSession?: Session;
@@ -92,6 +105,7 @@ export function ChatView({
const chat = useChat({ preferences, initialSession });
const { replaceSession } = chat;
const { canSeeImages } = useVisionSupport(preferences);
const { activity } = chat;
const [input, setInput] = useState(initialPrompt ?? "");
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
@@ -110,10 +124,30 @@ export function ChatView({
.catch(() => setSummaries([]));
}, []);
// Refreshed after each turn so the sidebar picks up a newly created chat.
// Refreshed after each turn so the sidebar picks up a newly created chat, and
// swept at the same time: a turn is exactly when a conversation's age changes,
// and waiting for the next launch to collect an expired one would mean showing
// the user a conversation that has already been deleted.
useEffect(() => {
reload();
}, [reload, chat.session.updatedAt]);
// Not until startup has finished, because until then the open conversation is
// still the placeholder one and the real conversation being restored has no
// id to be exempted by. Sweeping first could delete the chat on its way to
// the screen.
if (startup !== "ready") return;
let cancelled = false;
void (async () => {
// The open conversation is exempt: it is about to be rendered, and being
// the one it opens by default is the clearest sign it is still in use.
await runAutoDelete({ maxAgeMs: preferences.autoDeleteMs, keepId: chat.session.id });
if (!cancelled) reload();
})();
return () => {
cancelled = true;
};
}, [startup, chat.session.id, chat.session.updatedAt, preferences.autoDeleteMs, reload]);
/**
* Everything that has to happen before the first render, in one place.
@@ -188,6 +222,12 @@ export function ChatView({
// The preview comes first so the captured image is visible before the user
// decides what to ask about it, rather than only after sending.
pending ? `${renderAttachments([pending])}\n\n${PENDING_NOTE}` : undefined,
// Deliberately not gated on the Show Tool Calls preference. That setting is
// about the finished transcript, where a list of names adds nothing. This
// is about the turn in flight, where the same tool coming round a second
// time is the difference between a long answer and a stuck one, and hiding
// it would leave the user watching a spinner they cannot interpret.
activity ? describeActivity(activity) : undefined,
].filter((note): note is string => Boolean(note));
// The placeholder is what tells a user that the search bar is the composer.
@@ -198,7 +238,7 @@ export function ChatView({
thread.length === 0 ? empty : buildThreadMarkdown(thread, transcriptOptions);
return [...notes, transcript].filter((part) => part.length > 0).join("\n\n");
}, [launchNote, snipNote, pending, thread, transcriptOptions]);
}, [launchNote, snipNote, pending, activity, thread, transcriptOptions]);
const rows: SessionRow[] = useMemo(() => {
const base = summaries ?? [];
@@ -277,7 +317,7 @@ export function ChatView({
const hasPrompt = input.trim().length > 0 || pending !== undefined;
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
const stopAction = <Action title="Force Stop" icon={Icon.Stop} onAction={chat.cancel} />;
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
const newChatAction = (