Rename, delete with confirmation, pin, share and reorder moved from Home's session hook into the shared (app) shell, so /chart, /ephemeris, /reports and /people rows get the same menu and drag as /. Home only adds its paging cursor, what the open conversation does when deleted, and which local chats are unsaved. BUG-1467. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
import "./install-test-document";
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import React, { useEffect } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { AppSidebar } from "../src/components/app-sidebar.tsx";
|
|
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
|
import { useShellSessionActions } from "../src/hooks/use-shell-session-actions.ts";
|
|
import type { Account, ChatSession } from "../src/lib/home-types.ts";
|
|
import type { SessionListContextValue, ShellSessionHooks } from "../src/lib/session-list-context.tsx";
|
|
import { useSessionStore } from "../src/stores/session-store.ts";
|
|
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
|
|
|
Object.assign(globalThis, { React });
|
|
|
|
function syntheticSession(index: number): ChatSession {
|
|
return {
|
|
id: `session-${index}`,
|
|
title: `Synthetic session ${index}`,
|
|
theme: "general",
|
|
modelId: "test",
|
|
messages: [],
|
|
createdAt: 1,
|
|
updatedAt: 1_700_000_000_000 + index,
|
|
sessionType: "consultation",
|
|
rectificationCaseId: null,
|
|
chartProfileId: null,
|
|
chartProfileName: null,
|
|
chartProfileRole: null,
|
|
pinned: false,
|
|
archivedAt: null,
|
|
messagesHydrated: false,
|
|
};
|
|
}
|
|
|
|
const account = { user: { id: "user-1", email: "synthetic@example.test" }, credits: 3, profile: null, avatar: null } as unknown as Account;
|
|
|
|
function fakeList(): SessionListContextValue {
|
|
return {
|
|
sessions: [],
|
|
setSessions: () => {},
|
|
sessionsCursor: null,
|
|
setSessionsCursor: () => {},
|
|
account,
|
|
setAccount: () => {},
|
|
signedOut: false,
|
|
settled: true,
|
|
ready: Promise.resolve(),
|
|
waitUntilReady: async () => {},
|
|
boot: () => null,
|
|
reload: () => {},
|
|
registerShellControls: () => {},
|
|
};
|
|
}
|
|
|
|
const row = { id: "session-1", title: "Synthetic session 1", pinned: false, updatedAt: 0 };
|
|
const noop = () => {};
|
|
const sessionControls = {
|
|
menuSessionId: null,
|
|
disabled: false,
|
|
onMenuSessionChange: noop,
|
|
onTogglePinned: noop,
|
|
onRename: noop,
|
|
onShare: noop,
|
|
onDelete: noop,
|
|
};
|
|
|
|
// Issue #116: off `/` nothing registers Home's controls, yet the rows carry the menu.
|
|
test("a row off `/` has the 「…」 menu once the shell supplies session controls", () => {
|
|
const render = (withControls: boolean) => renderToStaticMarkup(
|
|
<SidebarProvider>
|
|
<AppSidebar
|
|
sessions={[row]}
|
|
activeSessionId={null}
|
|
account={null}
|
|
sessionControls={withControls ? sessionControls : null}
|
|
onOpenSession={noop}
|
|
/>
|
|
</SidebarProvider>,
|
|
);
|
|
const managed = render(true);
|
|
assert.match(managed, /session-menu-trigger/);
|
|
assert.doesNotMatch(managed, /data-readonly="true"/);
|
|
// Signed out or not read yet: still plain links.
|
|
assert.match(render(false), /data-readonly="true"/);
|
|
});
|
|
|
|
test("the shell deletes after confirmation, tells Home, and puts the row back when the server refuses", async () => {
|
|
const harness = createClientLifecycleHarness();
|
|
const originalFetch = globalThis.fetch;
|
|
const requests: string[] = [];
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
requests.push(`${init?.method ?? "GET"} ${String(input)}`);
|
|
return new Response(JSON.stringify({ error: "服务暂时不可用" }), { status: 503 });
|
|
}) as typeof fetch;
|
|
const sessions = [syntheticSession(1), syntheticSession(2)];
|
|
useSessionStore.setState({ sessions, pendingSessionDeletion: null, sessionMenuId: null, pendingSessionId: null, cancellationPending: false });
|
|
const deleted: Array<{ id: string; remaining: string[] }> = [];
|
|
const hooks: ShellSessionHooks = {
|
|
hasMore: false,
|
|
onLoadMore: noop,
|
|
onDeleted: (session, remaining) => { deleted.push({ id: session.id, remaining: remaining.map((item) => item.id) }); },
|
|
isSaved: () => true,
|
|
preview: () => false,
|
|
};
|
|
let actions: ReturnType<typeof useShellSessionActions> | null = null;
|
|
function Probe({ list, shellHooks }: { list: SessionListContextValue; shellHooks: ShellSessionHooks | null }) {
|
|
const value = useShellSessionActions(list, shellHooks);
|
|
useEffect(() => { actions = value; });
|
|
return null;
|
|
}
|
|
try {
|
|
await harness.render(<Probe list={fakeList()} shellHooks={hooks} />);
|
|
assert.ok(actions);
|
|
await harness.update(() => actions?.controls?.onDelete("session-1"));
|
|
assert.equal(useSessionStore.getState().pendingSessionDeletion?.id, "session-1", "delete asks first");
|
|
assert.deepEqual(requests, [], "nothing is sent before the confirmation");
|
|
await harness.update(() => actions?.confirmDeletion());
|
|
assert.equal(useSessionStore.getState().pendingSessionDeletion, null);
|
|
assert.deepEqual(deleted, [{ id: "session-1", remaining: ["session-2"] }]);
|
|
assert.deepEqual(requests, ["DELETE /api/sessions/session-1"]);
|
|
await harness.idle();
|
|
assert.deepEqual(useSessionStore.getState().sessions.map((item) => item.id), ["session-1", "session-2"], "rolled back");
|
|
assert.deepEqual(harness.errors, []);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
useSessionStore.setState({ sessions: [], pendingSessionDeletion: null });
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
test("signed out, the shell offers no session controls", async () => {
|
|
const harness = createClientLifecycleHarness();
|
|
let controls: unknown = "unset";
|
|
function Probe() {
|
|
const value = useShellSessionActions({ ...fakeList(), account: null, signedOut: true }, null);
|
|
useEffect(() => { controls = value.controls; });
|
|
return null;
|
|
}
|
|
try {
|
|
await harness.render(<Probe />);
|
|
assert.equal(controls, null);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|