import { ProjectId, ThreadId } from "@t3tools/contracts";import { describe, expect, it } from "vitest"; import { clearThreadUi, markThreadUnread, reorderProjects, setProjectExpanded, setThreadChangedFilesExpanded, syncProjects, syncThreads, type UiState,} from "./uiStateStore"; function makeUiState(overrides: Partial<UiState> = {}): UiState { return { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, ...overrides, };} describe("uiStateStore pure functions", () => { it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { const threadId = ThreadId.make("thread-1"); const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; const initialState = makeUiState({ threadLastVisitedAtById: { [threadId]: "2026-02-25T12:35:00.000Z", }, }); const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt); expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); }); it("markThreadUnread does not change a thread without a completed turn", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState({ threadLastVisitedAtById: { [threadId]: "2026-02-25T12:35:00.000Z", }, }); const next = markThreadUnread(initialState, threadId, null); expect(next).toBe(initialState); }); it("reorderProjects moves a project to a target index", () => { const project1 = ProjectId.make("project-1"); const project2 = ProjectId.make("project-2"); const project3 = ProjectId.make("project-3"); const initialState = makeUiState({ projectOrder: [project1, project2, project3], }); const next = reorderProjects(initialState, [project1], [project3]); expect(next.projectOrder).toEqual([project2, project3, project1]); }); it("reorderProjects is a no-op when dragged key is not in projectOrder", () => { const project1 = ProjectId.make("project-1"); const project2 = ProjectId.make("project-2"); const initialState = makeUiState({ projectOrder: [project1, project2], }); const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]); expect(next).toBe(initialState); }); it("reorderProjects moves all member keys of a multi-member group together", () => { const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; const keyB = "env-local:proj-b"; const keyC = "env-local:proj-c"; const initialState = makeUiState({ projectOrder: [keyALocal, keyARemote, keyB, keyC], }); const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); }); it("reorderProjects handles member keys scattered across projectOrder", () => { const keyALocal = "env-local:proj-a"; const keyB = "env-local:proj-b"; const keyARemote = "env-remote:proj-a"; const keyC = "env-local:proj-c"; const initialState = makeUiState({ projectOrder: [keyALocal, keyB, keyARemote, keyC], }); const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); }); it("reorderProjects places group after target when dragged from before a non-last target", () => { const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; const keyB = "env-local:proj-b"; const keyC = "env-local:proj-c"; const keyD = "env-local:proj-d"; const initialState = makeUiState({ projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD], }); const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]); expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]); }); it("reorderProjects places group before target when dragged from after", () => { const keyB = "env-local:proj-b"; const keyC = "env-local:proj-c"; const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; const initialState = makeUiState({ projectOrder: [keyB, keyC, keyALocal, keyARemote], }); const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]); expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]); }); it("reorderProjects with multi-member target inserts after first target occurrence", () => { const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; const keyBLocal = "env-local:proj-b"; const keyBRemote = "env-remote:proj-b"; const initialState = makeUiState({ projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote], }); const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]); // Target members may become non-contiguous; this is fine because the // sidebar groups by logical key using first-occurrence positioning. expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]); }); it("reorderProjects is a no-op when dragged group equals target group", () => { const key1 = "env-local:proj-a"; const key2 = "env-remote:proj-a"; const initialState = makeUiState({ projectOrder: [key1, key2, "env-local:proj-b"], }); const next = reorderProjects(initialState, [key1, key2], [key1, key2]); expect(next).toBe(initialState); }); it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => { const initialState = makeUiState({ projectOrder: ["env-local:proj-a", "env-local:proj-b"], }); const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]); expect(next).toBe(initialState); }); it("syncProjects preserves current project order during snapshot recovery", () => { const project1 = ProjectId.make("project-1"); const project2 = ProjectId.make("project-2"); const project3 = ProjectId.make("project-3"); const initialState = makeUiState({ projectExpandedById: { [project1]: true, [project2]: false, }, projectOrder: [project2, project1], }); const next = syncProjects(initialState, [ { key: project1, cwd: "/tmp/project-1" }, { key: project2, cwd: "/tmp/project-2" }, { key: project3, cwd: "/tmp/project-3" }, ]); expect(next.projectOrder).toEqual([project2, project1, project3]); expect(next.projectExpandedById[project2]).toBe(false); }); it("syncProjects preserves manual order when a project is recreated with the same cwd", () => { const oldProject1 = ProjectId.make("project-1"); const oldProject2 = ProjectId.make("project-2"); const recreatedProject2 = ProjectId.make("project-2b"); const initialState = syncProjects( makeUiState({ projectExpandedById: { [oldProject1]: true, [oldProject2]: false, }, projectOrder: [oldProject2, oldProject1], }), [ { key: oldProject1, cwd: "/tmp/project-1" }, { key: oldProject2, cwd: "/tmp/project-2" }, ], ); const next = syncProjects(initialState, [ { key: oldProject1, cwd: "/tmp/project-1" }, { key: recreatedProject2, cwd: "/tmp/project-2" }, ]); expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); expect(next.projectExpandedById[recreatedProject2]).toBe(false); }); it("syncProjects returns a new state when only project cwd changes", () => { const project1 = ProjectId.make("project-1"); const initialState = syncProjects( makeUiState({ projectExpandedById: { [project1]: false, }, projectOrder: [project1], }), [{ key: project1, cwd: "/tmp/project-1" }], ); const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]); expect(next).not.toBe(initialState); expect(next.projectOrder).toEqual([project1]); expect(next.projectExpandedById[project1]).toBe(false); }); it("syncThreads prunes missing thread UI state", () => { const thread1 = ThreadId.make("thread-1"); const thread2 = ThreadId.make("thread-2"); const initialState = makeUiState({ threadLastVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", [thread2]: "2026-02-25T12:36:00.000Z", }, threadChangedFilesExpandedById: { [thread1]: { "turn-1": false, }, [thread2]: { "turn-2": false, }, }, }); const next = syncThreads(initialState, [{ key: thread1 }]); expect(next.threadLastVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", }); expect(next.threadChangedFilesExpandedById).toEqual({ [thread1]: { "turn-1": false, }, }); }); it("syncThreads seeds visit state for unseen snapshot threads", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState(); const next = syncThreads(initialState, [ { key: thread1, seedVisitedAt: "2026-02-25T12:35:00.000Z", }, ]); expect(next.threadLastVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", }); }); it("setProjectExpanded updates expansion without touching order", () => { const project1 = ProjectId.make("project-1"); const initialState = makeUiState({ projectExpandedById: { [project1]: true, }, projectOrder: [project1], }); const next = setProjectExpanded(initialState, project1, false); expect(next.projectExpandedById[project1]).toBe(false); expect(next.projectOrder).toEqual([project1]); }); it("clearThreadUi removes visit state for deleted threads", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ threadLastVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", }, threadChangedFilesExpandedById: { [thread1]: { "turn-1": false, }, }, }); const next = clearThreadUi(initialState, thread1); expect(next.threadLastVisitedAtById).toEqual({}); expect(next.threadChangedFilesExpandedById).toEqual({}); }); it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState(); const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); expect(next.threadChangedFilesExpandedById).toEqual({ [thread1]: { "turn-1": false, }, }); }); it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ threadChangedFilesExpandedById: { [thread1]: { "turn-1": false, }, }, }); const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); expect(next.threadChangedFilesExpandedById).toEqual({}); });});
import { ProjectId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";
import {
clearThreadUi,
markThreadUnread,
reorderProjects,
setProjectExpanded,
setThreadChangedFilesExpanded,
syncProjects,
syncThreads,
type UiState,
} from "./uiStateStore";
function makeUiState(overrides: Partial<UiState> = {}): UiState {
return {
projectExpandedById: {},
projectOrder: [],
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
...overrides,
};
}
describe("uiStateStore pure functions", () => {
it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
const threadId = ThreadId.make("thread-1");
const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
const initialState = makeUiState({
threadLastVisitedAtById: {
[threadId]: "2026-02-25T12:35:00.000Z",
},
});
const next = markThreadUnread(initialState, threadId, latestTurnCompletedAt);
expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z");
});
it("markThreadUnread does not change a thread without a completed turn", () => {
const threadId = ThreadId.make("thread-1");
const initialState = makeUiState({
threadLastVisitedAtById: {
[threadId]: "2026-02-25T12:35:00.000Z",
},
});
const next = markThreadUnread(initialState, threadId, null);
expect(next).toBe(initialState);
});
it("reorderProjects moves a project to a target index", () => {
const project1 = ProjectId.make("project-1");
const project2 = ProjectId.make("project-2");
const project3 = ProjectId.make("project-3");
const initialState = makeUiState({
projectOrder: [project1, project2, project3],
});
const next = reorderProjects(initialState, [project1], [project3]);
expect(next.projectOrder).toEqual([project2, project3, project1]);
});
it("reorderProjects is a no-op when dragged key is not in projectOrder", () => {
const project1 = ProjectId.make("project-1");
const project2 = ProjectId.make("project-2");
const initialState = makeUiState({
projectOrder: [project1, project2],
});
const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]);
expect(next).toBe(initialState);
});
it("reorderProjects moves all member keys of a multi-member group together", () => {
const keyALocal = "env-local:proj-a";
const keyARemote = "env-remote:proj-a";
const keyB = "env-local:proj-b";
const keyC = "env-local:proj-c";
const initialState = makeUiState({
projectOrder: [keyALocal, keyARemote, keyB, keyC],
});
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
});
it("reorderProjects handles member keys scattered across projectOrder", () => {
const keyALocal = "env-local:proj-a";
const keyB = "env-local:proj-b";
const keyARemote = "env-remote:proj-a";
const keyC = "env-local:proj-c";
const initialState = makeUiState({
projectOrder: [keyALocal, keyB, keyARemote, keyC],
});
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
});
it("reorderProjects places group after target when dragged from before a non-last target", () => {
const keyALocal = "env-local:proj-a";
const keyARemote = "env-remote:proj-a";
const keyB = "env-local:proj-b";
const keyC = "env-local:proj-c";
const keyD = "env-local:proj-d";
const initialState = makeUiState({
projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD],
});
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]);
});
it("reorderProjects places group before target when dragged from after", () => {
const keyB = "env-local:proj-b";
const keyC = "env-local:proj-c";
const keyALocal = "env-local:proj-a";
const keyARemote = "env-remote:proj-a";
const initialState = makeUiState({
projectOrder: [keyB, keyC, keyALocal, keyARemote],
});
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]);
expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]);
});
it("reorderProjects with multi-member target inserts after first target occurrence", () => {
const keyALocal = "env-local:proj-a";
const keyARemote = "env-remote:proj-a";
const keyBLocal = "env-local:proj-b";
const keyBRemote = "env-remote:proj-b";
const initialState = makeUiState({
projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote],
});
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]);
// Target members may become non-contiguous; this is fine because the
// sidebar groups by logical key using first-occurrence positioning.
expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]);
});
it("reorderProjects is a no-op when dragged group equals target group", () => {
const key1 = "env-local:proj-a";
const key2 = "env-remote:proj-a";
const initialState = makeUiState({
projectOrder: [key1, key2, "env-local:proj-b"],
});
const next = reorderProjects(initialState, [key1, key2], [key1, key2]);
expect(next).toBe(initialState);
});
it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => {
const initialState = makeUiState({
projectOrder: ["env-local:proj-a", "env-local:proj-b"],
});
const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]);
expect(next).toBe(initialState);
});
it("syncProjects preserves current project order during snapshot recovery", () => {
const project1 = ProjectId.make("project-1");
const project2 = ProjectId.make("project-2");
const project3 = ProjectId.make("project-3");
const initialState = makeUiState({
projectExpandedById: {
[project1]: true,
[project2]: false,
},
projectOrder: [project2, project1],
});
const next = syncProjects(initialState, [
{ key: project1, cwd: "/tmp/project-1" },
{ key: project2, cwd: "/tmp/project-2" },
{ key: project3, cwd: "/tmp/project-3" },
]);
expect(next.projectOrder).toEqual([project2, project1, project3]);
expect(next.projectExpandedById[project2]).toBe(false);
});
it("syncProjects preserves manual order when a project is recreated with the same cwd", () => {
const oldProject1 = ProjectId.make("project-1");
const oldProject2 = ProjectId.make("project-2");
const recreatedProject2 = ProjectId.make("project-2b");
const initialState = syncProjects(
makeUiState({
projectExpandedById: {
[oldProject1]: true,
[oldProject2]: false,
},
projectOrder: [oldProject2, oldProject1],
}),
[
{ key: oldProject1, cwd: "/tmp/project-1" },
{ key: oldProject2, cwd: "/tmp/project-2" },
],
);
const next = syncProjects(initialState, [
{ key: oldProject1, cwd: "/tmp/project-1" },
{ key: recreatedProject2, cwd: "/tmp/project-2" },
]);
expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);
expect(next.projectExpandedById[recreatedProject2]).toBe(false);
});
it("syncProjects returns a new state when only project cwd changes", () => {
const project1 = ProjectId.make("project-1");
const initialState = syncProjects(
makeUiState({
projectExpandedById: {
[project1]: false,
},
projectOrder: [project1],
}),
[{ key: project1, cwd: "/tmp/project-1" }],
);
const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]);
expect(next).not.toBe(initialState);
expect(next.projectOrder).toEqual([project1]);
expect(next.projectExpandedById[project1]).toBe(false);
});
it("syncThreads prunes missing thread UI state", () => {
const thread1 = ThreadId.make("thread-1");
const thread2 = ThreadId.make("thread-2");
const initialState = makeUiState({
threadLastVisitedAtById: {
[thread1]: "2026-02-25T12:35:00.000Z",
[thread2]: "2026-02-25T12:36:00.000Z",
},
threadChangedFilesExpandedById: {
[thread1]: {
"turn-1": false,
},
[thread2]: {
"turn-2": false,
},
},
});
const next = syncThreads(initialState, [{ key: thread1 }]);
expect(next.threadLastVisitedAtById).toEqual({
[thread1]: "2026-02-25T12:35:00.000Z",
});
expect(next.threadChangedFilesExpandedById).toEqual({
[thread1]: {
"turn-1": false,
},
});
});
it("syncThreads seeds visit state for unseen snapshot threads", () => {
const thread1 = ThreadId.make("thread-1");
const initialState = makeUiState();
const next = syncThreads(initialState, [
{
key: thread1,
seedVisitedAt: "2026-02-25T12:35:00.000Z",
},
]);
expect(next.threadLastVisitedAtById).toEqual({
[thread1]: "2026-02-25T12:35:00.000Z",
});
});
it("setProjectExpanded updates expansion without touching order", () => {
const project1 = ProjectId.make("project-1");
const initialState = makeUiState({
projectExpandedById: {
[project1]: true,
},
projectOrder: [project1],
});
const next = setProjectExpanded(initialState, project1, false);
expect(next.projectExpandedById[project1]).toBe(false);
expect(next.projectOrder).toEqual([project1]);
});
it("clearThreadUi removes visit state for deleted threads", () => {
const thread1 = ThreadId.make("thread-1");
const initialState = makeUiState({
threadLastVisitedAtById: {
[thread1]: "2026-02-25T12:35:00.000Z",
},
threadChangedFilesExpandedById: {
[thread1]: {
"turn-1": false,
},
},
});
const next = clearThreadUi(initialState, thread1);
expect(next.threadLastVisitedAtById).toEqual({});
expect(next.threadChangedFilesExpandedById).toEqual({});
});
it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => {
const thread1 = ThreadId.make("thread-1");
const initialState = makeUiState();
const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false);
expect(next.threadChangedFilesExpandedById).toEqual({
[thread1]: {
"turn-1": false,
},
});
});
it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => {
const thread1 = ThreadId.make("thread-1");
const initialState = makeUiState({
threadChangedFilesExpandedById: {
[thread1]: {
"turn-1": false,
},
},
});
const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true);
expect(next.threadChangedFilesExpandedById).toEqual({});
});
});