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:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user