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; listSessions: () => Promise; readAttachment: (path: string) => Promise; } 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): boolean { return Boolean(input.initialSessionId || input.initialAttachmentPath); } export async function resolveStartup(input: StartupInput, ports: StartupPorts): Promise { 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; }