fix: stop the chat view hanging on "Loading conversations…" after a snip
The hand-off launch deadlocked its own startup. `resumed` was initialised to `Boolean(initialSession)`, which is false for a snip, and the idle-resume effect returned early whenever there was a hand-off, so nothing ever set it. The render gate needed both `resumed` and `prepared`, so a successful snip left the view spinning and there was no way to reach the composer. Two effects guarding two booleans had already produced one bug here, so this fixes the shape rather than the value: - One startup effect and one `startup` flag, cleared in a `finally`. Every branch ends in the same place, including a thrown one, so no path can leave the view loading. - The decision tree moves to `lib/startup.ts` as a pure `resolveStartup`, which takes its storage and file reads as injected ports. It is now covered by 16 tests, including the case that broke: a hand-off must not fall through to the idle resume, and must still finish startup. - `startup.ts` joins the guard that keeps the logic layer free of Vicinae imports. - ChatView is prettier-clean; it had been carrying pre-existing drift and has been rewritten twice this session anyway. Verified: 273 tests, tsc, eslint, prettier, and vici build.
This commit is contained in:
@@ -6,9 +6,10 @@ import { useVisionSupport } from "../hooks/useVision";
|
||||
import { buildThreadMarkdown, type TranscriptOptions } from "../markdown";
|
||||
import { getLamPreferences } from "../preferences";
|
||||
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
|
||||
import { resolveStartup } from "../startup";
|
||||
import { buildThread } from "../thread";
|
||||
import type { PendingAttachment, Session, SessionSummary } from "../types";
|
||||
import { errorMessage, recencyGroup, truncate } from "../utils";
|
||||
import { recencyGroup, truncate } from "../utils";
|
||||
|
||||
/**
|
||||
* The chat surface: conversations on the left, the open conversation on the right.
|
||||
@@ -92,13 +93,10 @@ export function ChatView({
|
||||
const { replaceSession } = chat;
|
||||
const { canSeeImages } = useVisionSupport(preferences);
|
||||
|
||||
/** A launch that hands something over has to wait for it before it can render. */
|
||||
const needsPreparation = Boolean(initialSessionId) || Boolean(initialAttachmentPath);
|
||||
|
||||
const [input, setInput] = useState(initialPrompt ?? "");
|
||||
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
|
||||
const [prepared, setPrepared] = useState(!needsPreparation);
|
||||
const [resumed, setResumed] = useState(Boolean(initialSession));
|
||||
// One flag, not two. See the startup effect below for why that matters.
|
||||
const [startup, setStartup] = useState<"loading" | "ready">(initialSession ? "ready" : "loading");
|
||||
// A captured image is held here until the user says what they want to know
|
||||
// about it. Nothing is sent to the model on capture alone.
|
||||
const [pending, setPending] = useState<PendingAttachment | undefined>(undefined);
|
||||
@@ -118,76 +116,47 @@ export function ChatView({
|
||||
}, [reload, chat.session.updatedAt]);
|
||||
|
||||
/**
|
||||
* Restores whatever the launching command handed over.
|
||||
* Everything that has to happen before the first render, in one place.
|
||||
*
|
||||
* This is the second half of the snip handoff: the capture command closed the
|
||||
* window to take the shot, so it comes back here with a file to read and, when
|
||||
* the snip was started from a conversation, the id of the conversation to
|
||||
* return to. Reloading by id rather than trusting "most recent" matters
|
||||
* because the snip itself is not a turn, so nothing was saved in between.
|
||||
* This used to be two effects guarding two booleans, and a hand-off launch
|
||||
* satisfied the guard of *both* while setting neither, so `resumed` stayed
|
||||
* false and the view sat on "Loading conversations…" forever. Anything that
|
||||
* blocks the first render belongs in one effect with one flag, and the flag is
|
||||
* cleared in a `finally` so that no branch, including a thrown one, can leave
|
||||
* the view spinning.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!needsPreparation) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
if (initialSessionId) {
|
||||
const session = await loadSession(initialSessionId);
|
||||
if (!cancelled && session) replaceSession(session);
|
||||
}
|
||||
|
||||
if (initialAttachmentPath) {
|
||||
try {
|
||||
const attachment = await readAttachment(initialAttachmentPath);
|
||||
if (!cancelled) setPending(attachment);
|
||||
} catch (thrown) {
|
||||
if (!cancelled) setSnipNote(`**Could not open the screenshot:** ${errorMessage(thrown)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setPrepared(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needsPreparation, initialSessionId, initialAttachmentPath, replaceSession]);
|
||||
|
||||
/**
|
||||
* Reopening the command continues the most recent conversation, but only if it
|
||||
* was used recently. Past the configured idle window it starts a fresh chat
|
||||
* instead. The previous conversation is never removed, it just drops down the
|
||||
* sidebar. Setting the preference to "Never" keeps the last chat open forever.
|
||||
*
|
||||
* A hand-off launch opts out: it either names the conversation to return to or
|
||||
* deliberately starts a new one, and quietly continuing some other chat would
|
||||
* bury the screenshot in an unrelated thread.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialSession || needsPreparation) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [latest] = await listSessions();
|
||||
if (!latest) return;
|
||||
const outcome = await resolveStartup(
|
||||
{
|
||||
hasInitialSession: Boolean(initialSession),
|
||||
initialSessionId,
|
||||
initialAttachmentPath,
|
||||
resumeTimeoutMs: preferences.resumeTimeoutMs,
|
||||
now: Date.now(),
|
||||
},
|
||||
{ loadSession, listSessions, readAttachment },
|
||||
);
|
||||
|
||||
const idleMs = Date.now() - latest.updatedAt;
|
||||
const { resumeTimeoutMs } = preferences;
|
||||
if (resumeTimeoutMs !== undefined && idleMs > resumeTimeoutMs) return;
|
||||
if (cancelled) return;
|
||||
|
||||
const session = await loadSession(latest.id);
|
||||
if (!cancelled && session) replaceSession(session);
|
||||
if (outcome.session) replaceSession(outcome.session);
|
||||
if (outcome.attachment) setPending(outcome.attachment);
|
||||
if (outcome.attachmentError) {
|
||||
setSnipNote(`**Could not open the screenshot:** ${outcome.attachmentError}`);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setResumed(true);
|
||||
if (!cancelled) setStartup("ready");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialSession, needsPreparation, preferences, replaceSession]);
|
||||
}, [initialSession, initialSessionId, initialAttachmentPath, preferences.resumeTimeoutMs, replaceSession]);
|
||||
|
||||
const thread = useMemo(
|
||||
() =>
|
||||
@@ -213,8 +182,8 @@ export function ChatView({
|
||||
* already looking.
|
||||
*/
|
||||
const threadMarkdown = useMemo(() => {
|
||||
const notes = [launchNote, snipNote, pending ? PENDING_NOTE : undefined].filter(
|
||||
(note): note is string => Boolean(note),
|
||||
const notes = [launchNote, snipNote, pending ? PENDING_NOTE : undefined].filter((note): note is string =>
|
||||
Boolean(note),
|
||||
);
|
||||
|
||||
const transcript =
|
||||
@@ -237,10 +206,7 @@ export function ChatView({
|
||||
if (base.some((summary) => summary.id === activeId)) return mapped;
|
||||
|
||||
const active = summarizeSession(chat.session);
|
||||
return [
|
||||
{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true },
|
||||
...mapped,
|
||||
];
|
||||
return [{ id: active.id, title: active.title, updatedAt: active.updatedAt, isActive: true }, ...mapped];
|
||||
}, [summaries, activeId, chat.session]);
|
||||
|
||||
/**
|
||||
@@ -282,8 +248,7 @@ export function ChatView({
|
||||
|
||||
/** The snip a "show me the screenshot" action would open: the pending one, else the newest. */
|
||||
const latestAttachment =
|
||||
pending ??
|
||||
[...chat.session.turns].reverse().find((turn) => turn.attachments.length > 0)?.attachments[0];
|
||||
pending ?? [...chat.session.turns].reverse().find((turn) => turn.attachments.length > 0)?.attachments[0];
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
@@ -382,13 +347,11 @@ export function ChatView({
|
||||
);
|
||||
};
|
||||
|
||||
if (!resumed || !prepared || summaries === undefined) {
|
||||
if (startup === "loading" || summaries === undefined) {
|
||||
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
|
||||
}
|
||||
|
||||
const placeholder = chat.status
|
||||
? `${chat.status}. Press ⏎ to stop`
|
||||
: "Lam is thinking. Press ⏎ to stop";
|
||||
const placeholder = chat.status ? `${chat.status}. Press ⏎ to stop` : "Lam is thinking. Press ⏎ to stop";
|
||||
|
||||
const idlePlaceholder = pending ? "Ask about the screenshot…" : "Ask Lam anything…";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user