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:
2026-10-04 15:34:09 +08:00
parent ada98968ac
commit e00b49bbe5
4 changed files with 345 additions and 73 deletions
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from "vitest";
import { isHandoff, resolveStartup, type StartupPorts } from "../lib/startup";
import type { PendingAttachment, Session, SessionSummary } from "../lib/types";
/**
* The chat view's startup is the part that kept breaking: three asynchronous
* paths (a pushed session, a handed-over capture, an idle resume) that all have
* to end in a rendered chat. Two of those bugs shipped, so the decision tree is
* tested here rather than discovered from a screenshot of a spinner.
*/
const NOW = 1_000_000;
const HOUR = 3_600_000;
function session(id: string, updatedAt: number): Session {
return { id, title: id, model: "m", createdAt: updatedAt, updatedAt, turns: [] };
}
function summary(id: string, updatedAt: number): SessionSummary {
return { id, title: id, model: "m", createdAt: updatedAt, updatedAt, turnCount: 0, preview: "" };
}
function attachment(path: string): PendingAttachment {
return { path, mime: "image/png", base64: "AAAA" };
}
/** Ports that record what was asked for, so "did not resume" is checkable. */
function ports(overrides: Partial<StartupPorts> = {}) {
const loadSession = vi.fn(async (id: string) => session(id, NOW));
const listSessions = vi.fn(async () => [summary("latest", NOW)]);
const readAttachment = vi.fn(async (path: string) => attachment(path));
return {
loadSession,
listSessions,
readAttachment,
...overrides,
} satisfies StartupPorts;
}
function input(overrides: Partial<Parameters<typeof resolveStartup>[0]> = {}) {
return {
hasInitialSession: false,
now: NOW,
...overrides,
};
}
describe("isHandoff", () => {
it("is true when a file was handed over", () => {
expect(isHandoff({ initialAttachmentPath: "/tmp/snip.png" })).toBe(true);
});
it("is true when a conversation was named", () => {
expect(isHandoff({ initialSessionId: "abc" })).toBe(true);
});
it("is false on an ordinary open", () => {
expect(isHandoff({})).toBe(false);
});
});
describe("resolveStartup", () => {
describe("a snip hand-off", () => {
it("reads the attachment and restores the named conversation", async () => {
const p = ports();
const result = await resolveStartup(
input({ initialSessionId: "abc", initialAttachmentPath: "/tmp/snip.png" }),
p,
);
expect(result.session?.id).toBe("abc");
expect(result.attachment?.path).toBe("/tmp/snip.png");
});
it("does not fall through to the idle resume", async () => {
// The bug this file exists for: a hand-off has to finish startup even
// though it took the resume path off the table. Guarding the resume with
// "and there was no hand-off" while forgetting to clear the readiness flag
// left the view on its loading row forever.
const p = ports();
await resolveStartup(input({ initialAttachmentPath: "/tmp/snip.png" }), p);
expect(p.listSessions).not.toHaveBeenCalled();
});
it("does not attach the most recent conversation's session", async () => {
const p = ports();
const result = await resolveStartup(input({ initialAttachmentPath: "/tmp/snip.png" }), p);
expect(result.session).toBeUndefined();
});
it("still resolves when the named conversation has been deleted", async () => {
// A snip started from a conversation someone has since deleted should mean
// a fresh chat with the image, not a spinner or a crash.
const p = ports({ loadSession: vi.fn(async () => undefined) });
const result = await resolveStartup(
input({ initialSessionId: "gone", initialAttachmentPath: "/tmp/snip.png" }),
p,
);
expect(result.session).toBeUndefined();
expect(result.attachment?.path).toBe("/tmp/snip.png");
});
it("reports an unreadable attachment without refusing to open", async () => {
const p = ports({
readAttachment: vi.fn(async () => {
throw new Error("ENOENT: no such file");
}),
});
const result = await resolveStartup(input({ initialAttachmentPath: "/tmp/gone.png" }), p);
expect(result.attachment).toBeUndefined();
expect(result.attachmentError).toContain("ENOENT");
});
it("survives a storage failure while loading the named conversation", async () => {
const p = ports({
loadSession: vi.fn(async () => {
throw new Error("storage exploded");
}),
});
const result = await resolveStartup(input({ initialSessionId: "abc" }), p);
expect(result.session).toBeUndefined();
});
});
describe("an ordinary open", () => {
it("resumes the most recent conversation when it is fresh", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 60_000)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result.session?.id).toBe("latest");
expect(p.loadSession).toHaveBeenCalledWith("latest");
});
it("starts fresh when the newest conversation is older than the window", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 2 * HOUR)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result.session).toBeUndefined();
expect(p.loadSession).not.toHaveBeenCalled();
});
it("treats exactly the window as fresh", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - HOUR)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result.session?.id).toBe("latest");
});
it("always resumes when the preference is 'never time out'", async () => {
const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 400 * HOUR)]) });
const result = await resolveStartup(input({ resumeTimeoutMs: undefined }), p);
expect(result.session?.id).toBe("latest");
});
it("starts fresh when there are no conversations at all", async () => {
const p = ports({ listSessions: vi.fn(async () => []) });
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result).toEqual({});
expect(p.loadSession).not.toHaveBeenCalled();
});
it("starts fresh rather than failing when the index cannot be read", async () => {
const p = ports({
listSessions: vi.fn(async () => {
throw new Error("corrupt index");
}),
});
const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p);
expect(result).toEqual({});
});
it("does not resume when a session was pushed in by hand", async () => {
// The sessions command already knows which conversation to show.
const p = ports();
await resolveStartup(input({ hasInitialSession: true }), p);
expect(p.listSessions).not.toHaveBeenCalled();
expect(p.loadSession).not.toHaveBeenCalled();
});
});
});