Files
lam-agent-vicinae/src/lib/startup.ts
T

97 lines
3.8 KiB
TypeScript

import type { PendingAttachment, Session, SessionSummary } from "./types";
import { errorMessage } from "./utils";
/**
* Everything the chat view resolves before its first render.
*
* Pulled out of the component and made pure because this is the part that keeps
* going wrong. Three things can happen on open, and all of them are
* asynchronous: a launch hands over a captured file, it may also name the
* conversation to return to, and an ordinary open resumes the most recent
* conversation if it is fresh enough. Every path has to end in a rendered chat.
*
* As a plain function it is testable without mounting anything, which matters
* because the alternative is finding out from a screenshot that a view is
* spinning forever.
*
* Imports nothing from Vicinae, so it belongs to the logic layer.
*/
export interface StartupInput {
/** A conversation already in hand, which is how the sessions command pushes this view. */
hasInitialSession: boolean;
/** A conversation to reopen by id, handed over by the snip command. */
initialSessionId?: string;
/** A captured image to read in. */
initialAttachmentPath?: string;
/** Idle time past which an ordinary open starts fresh. Undefined means never time out. */
resumeTimeoutMs?: number;
/** Injected so the resume window is deterministic under test. */
now: number;
}
/** The side effects, injected so nothing here touches storage or the file system directly. */
export interface StartupPorts {
loadSession: (id: string) => Promise<Session | undefined>;
listSessions: () => Promise<SessionSummary[]>;
readAttachment: (path: string) => Promise<PendingAttachment>;
}
export interface StartupResult {
/** The conversation to show, when one was restored. */
session?: Session;
/** The image that was handed over, when one could be read. */
attachment?: PendingAttachment;
/** Why a handed-over image could not be read, so the view can say so. */
attachmentError?: string;
}
/** True when the launch handed something over, which also means "do not resume". */
export function isHandoff(input: Pick<StartupInput, "initialSessionId" | "initialAttachmentPath">): boolean {
return Boolean(input.initialSessionId || input.initialAttachmentPath);
}
export async function resolveStartup(input: StartupInput, ports: StartupPorts): Promise<StartupResult> {
const result: StartupResult = {};
if (input.initialSessionId) {
// A hand-off names the conversation to return to, so that one is restored
// rather than whichever chat happens to be most recent. By id matters
// because taking a snip is not a turn, so nothing was saved in between.
// A session that has since been deleted just means starting fresh.
result.session = await ports.loadSession(input.initialSessionId).catch(() => undefined);
}
if (input.initialAttachmentPath) {
try {
result.attachment = await ports.readAttachment(input.initialAttachmentPath);
} catch (thrown) {
// A missing file leaves an empty chat with an explanation, which beats
// refusing to open at all.
result.attachmentError = errorMessage(thrown);
}
}
// Nothing was handed over, so the idle-resume rule applies: reopening the
// command continues the most recent conversation, but only if it was used
// recently. The previous conversation is never removed either way, it just
// drops down the sidebar.
if (!input.hasInitialSession && !isHandoff(input)) {
const latest = await ports
.listSessions()
.then((sessions) => sessions[0])
.catch(() => undefined);
if (latest) {
const idleMs = input.now - latest.updatedAt;
const fresh = input.resumeTimeoutMs === undefined || idleMs <= input.resumeTimeoutMs;
if (fresh) {
result.session = await ports.loadSession(latest.id).catch(() => undefined);
}
}
}
return result;
}