authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 10:12:00-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
log506a5260dca5a5fd6324a33262be497a1d963145
tree8b02dbef57bf5fa16863e3ca74985d927c77e048
parentd4c90d696d2546466d364cfccc86bcf2be6a7c86

Refactor store actions to use direct Zustand state updaters

- Remove Action union/reducer dispatch path in `store.ts` - Extract per-action state transition helpers and apply via `setAppState` - Update `store.test.ts` to validate behavior through `useStore` actions

2 files changed, 442 insertions(+), 436 deletions(-)

apps/web/src/store.test.ts+43-35
......@@ -1,7 +1,7 @@
11import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
2import { beforeEach, describe, expect, it } from "vitest";
33
4import { reducer, type AppState } from "./store";
4import { type AppState, useStore } from "./store";
55import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
......@@ -54,7 +54,16 @@ function makeState(thread: Thread): AppState {
5454 };
5555}
5656
57describe("store reducer", () => {
57describe("store markThreadUnread action", () => {
58 beforeEach(() => {
59 useStore.setState({
60 projects: [],
61 threads: [],
62 threadsHydrated: false,
63 runtimeMode: "full-access",
64 });
65 });
66
5867 it("marks a completed thread as unread by moving lastVisitedAt before completion", () => {
5968 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
6069 const initialState = makeState(
......@@ -71,12 +80,10 @@ describe("store reducer", () => {
7180 }),
7281 );
7382
74 const next = reducer(initialState, {
75 type: "MARK_THREAD_UNREAD",
76 threadId: ThreadId.makeUnsafe("thread-1"),
77 });
83 useStore.setState(initialState);
84 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
7885
79 const updatedThread = next.threads[0];
86 const updatedThread = useStore.getState().threads[0];
8087 expect(updatedThread).toBeDefined();
8188 expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z");
8289 expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan(
......@@ -92,16 +99,23 @@ describe("store reducer", () => {
9299 }),
93100 );
94101
95 const next = reducer(initialState, {
96 type: "MARK_THREAD_UNREAD",
97 threadId: ThreadId.makeUnsafe("thread-1"),
98 });
102 useStore.setState(initialState);
103 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
99104
100 expect(next).toEqual(initialState);
105 expect(useStore.getState().threads[0]?.lastVisitedAt).toBe("2026-02-25T12:35:00.000Z");
101106 });
102107});
103108
104describe("store terminal activity reducer", () => {
109describe("store terminal activity action", () => {
110 beforeEach(() => {
111 useStore.setState({
112 projects: [],
113 threads: [],
114 threadsHydrated: false,
115 runtimeMode: "full-access",
116 });
117 });
118
105119 it("adds a terminal to runningTerminalIds when subprocess activity starts", () => {
106120 const state = makeState(
107121 makeThread({
......@@ -114,14 +128,12 @@ describe("store terminal activity reducer", () => {
114128 ],
115129 }),
116130 );
117 const next = reducer(state, {
118 type: "SET_THREAD_TERMINAL_ACTIVITY",
119 threadId: ThreadId.makeUnsafe("thread-1"),
120 terminalId: "alt",
121 hasRunningSubprocess: true,
122 });
131 useStore.setState(state);
132 useStore
133 .getState()
134 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", true);
123135
124 expect(next.threads[0]?.runningTerminalIds).toEqual(["alt"]);
136 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual(["alt"]);
125137 });
126138
127139 it("removes a terminal from runningTerminalIds when subprocess activity stops", () => {
......@@ -137,25 +149,21 @@ describe("store terminal activity reducer", () => {
137149 runningTerminalIds: ["alt"],
138150 }),
139151 );
140 const next = reducer(state, {
141 type: "SET_THREAD_TERMINAL_ACTIVITY",
142 threadId: ThreadId.makeUnsafe("thread-1"),
143 terminalId: "alt",
144 hasRunningSubprocess: false,
145 });
152 useStore.setState(state);
153 useStore
154 .getState()
155 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", false);
146156
147 expect(next.threads[0]?.runningTerminalIds).toEqual([]);
157 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
148158 });
149159
150160 it("ignores activity events for unknown terminal ids", () => {
151161 const state = makeState(makeThread());
152 const next = reducer(state, {
153 type: "SET_THREAD_TERMINAL_ACTIVITY",
154 threadId: ThreadId.makeUnsafe("thread-1"),
155 terminalId: "missing",
156 hasRunningSubprocess: true,
157 });
162 useStore.setState(state);
163 useStore
164 .getState()
165 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "missing", true);
158166
159 expect(next.threads[0]?.runningTerminalIds).toEqual([]);
167 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
160168 });
161169});
apps/web/src/store.ts+399-401
......@@ -21,36 +21,6 @@ import {
2121 type ThreadTerminalGroup,
2222} from "./types";
2323
24// ── Actions ──────────────────────────────────────────────────────────
25
26type Action =
27 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
28 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
29 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }
30 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
31 | {
32 type: "SET_THREAD_TERMINAL_ACTIVITY";
33 threadId: ThreadId;
34 terminalId: string;
35 hasRunningSubprocess: boolean;
36 }
37 | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
38 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
39 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
40 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
41 | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
42 | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
43 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string }
44 | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
45 | { type: "SET_ERROR"; threadId: ThreadId; error: string | null }
46 | {
47 type: "SET_THREAD_BRANCH";
48 threadId: ThreadId;
49 branch: string | null;
50 worktreePath: string | null;
51 }
52 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
53
5424// ── State ────────────────────────────────────────────────────────────
5525
5626export interface AppState {
......@@ -455,369 +425,399 @@ function setThreadTerminalActivity(
455425 });
456426}
457427
458// ── Reducer ──────────────────────────────────────────────────────────
459
460export function reducer(state: AppState, action: Action): AppState {
461 switch (action.type) {
462 case "SYNC_SERVER_READ_MODEL": {
463 const projects = mapProjectsFromReadModel(
464 action.readModel.projects.filter((project) => project.deletedAt === null),
465 state.projects,
466 );
467 const existingThreadById = new Map(
468 state.threads.map((thread) => [thread.id, thread] as const),
469 );
470 const threads = action.readModel.threads
471 .filter((thread) => thread.deletedAt === null)
472 .map((thread) => {
473 const existing = existingThreadById.get(thread.id);
474
475 return normalizeThreadTerminals({
476 id: thread.id,
477 codexThreadId: thread.session?.providerThreadId ?? null,
478 projectId: thread.projectId,
479 title: thread.title,
480 model: resolveModelSlug(thread.model),
481 terminalOpen: existing?.terminalOpen ?? false,
482 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
483 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
484 runningTerminalIds: existing?.runningTerminalIds ?? [],
485 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
486 terminalGroups: existing?.terminalGroups ?? [
487 {
488 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
489 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
490 },
491 ],
492 activeTerminalGroupId:
493 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
494 session: thread.session
495 ? {
496 sessionId:
497 thread.session.providerSessionId ??
498 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
499 provider: toLegacyProvider(thread.session.providerName),
500 status: toLegacySessionStatus(thread.session.status),
501 orchestrationStatus: thread.session.status,
502 threadId: thread.session.providerThreadId,
503 activeTurnId: thread.session.activeTurnId ?? undefined,
504 createdAt: thread.session.updatedAt,
505 updatedAt: thread.session.updatedAt,
506 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
507 }
508 : null,
509 messages: thread.messages.map((message) => {
510 const attachments = message.attachments?.map((attachment) => ({
511 type: "image" as const,
512 id: attachment.id,
513 name: attachment.name,
514 mimeType: attachment.mimeType,
515 sizeBytes: attachment.sizeBytes,
516 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
517 }));
518 const normalizedMessage: ChatMessage = {
519 id: message.id,
520 role: message.role,
521 text: message.text,
522 createdAt: message.createdAt,
523 streaming: message.streaming,
524 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
525 ...(attachments && attachments.length > 0 ? { attachments } : {}),
526 };
527 return normalizedMessage;
528 }),
529 error: thread.session?.lastError ?? null,
530 createdAt: thread.createdAt,
531 latestTurn: thread.latestTurn,
532 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
533 branch: thread.branch,
534 worktreePath: thread.worktreePath,
535 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
536 turnId: checkpoint.turnId,
537 completedAt: checkpoint.completedAt,
538 status: checkpoint.status,
539 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
540 checkpointTurnCount: checkpoint.checkpointTurnCount,
541 checkpointRef: checkpoint.checkpointRef,
542 files: checkpoint.files.map((file) => ({ ...file })),
543 })),
544 activities: thread.activities.map((activity) => ({ ...activity })),
545 });
546 });
547 return {
548 ...state,
549 projects,
550 threads,
551 threadsHydrated: true,
552 };
553 }
554
555 case "MARK_THREAD_VISITED": {
556 const visitedAt = action.visitedAt ?? new Date().toISOString();
557 const visitedAtMs = Date.parse(visitedAt);
558 return {
559 ...state,
560 threads: updateThread(state.threads, action.threadId, (thread) => {
561 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
562 if (
563 Number.isFinite(previousVisitedAtMs) &&
564 Number.isFinite(visitedAtMs) &&
565 previousVisitedAtMs >= visitedAtMs
566 ) {
567 return thread;
568 }
569 return {
570 ...thread,
571 lastVisitedAt: visitedAt,
428function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState {
429 const projects = mapProjectsFromReadModel(
430 readModel.projects.filter((project) => project.deletedAt === null),
431 state.projects,
432 );
433 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));
434 const threads = readModel.threads
435 .filter((thread) => thread.deletedAt === null)
436 .map((thread) => {
437 const existing = existingThreadById.get(thread.id);
438
439 return normalizeThreadTerminals({
440 id: thread.id,
441 codexThreadId: thread.session?.providerThreadId ?? null,
442 projectId: thread.projectId,
443 title: thread.title,
444 model: resolveModelSlug(thread.model),
445 terminalOpen: existing?.terminalOpen ?? false,
446 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
447 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
448 runningTerminalIds: existing?.runningTerminalIds ?? [],
449 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
450 terminalGroups: existing?.terminalGroups ?? [
451 {
452 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
453 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
454 },
455 ],
456 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
457 session: thread.session
458 ? {
459 sessionId:
460 thread.session.providerSessionId ?? ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
461 provider: toLegacyProvider(thread.session.providerName),
462 status: toLegacySessionStatus(thread.session.status),
463 orchestrationStatus: thread.session.status,
464 threadId: thread.session.providerThreadId,
465 activeTurnId: thread.session.activeTurnId ?? undefined,
466 createdAt: thread.session.updatedAt,
467 updatedAt: thread.session.updatedAt,
468 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
469 }
470 : null,
471 messages: thread.messages.map((message) => {
472 const attachments = message.attachments?.map((attachment) => ({
473 type: "image" as const,
474 id: attachment.id,
475 name: attachment.name,
476 mimeType: attachment.mimeType,
477 sizeBytes: attachment.sizeBytes,
478 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
479 }));
480 const normalizedMessage: ChatMessage = {
481 id: message.id,
482 role: message.role,
483 text: message.text,
484 createdAt: message.createdAt,
485 streaming: message.streaming,
486 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
487 ...(attachments && attachments.length > 0 ? { attachments } : {}),
572488 };
489 return normalizedMessage;
573490 }),
574 };
575 }
491 error: thread.session?.lastError ?? null,
492 createdAt: thread.createdAt,
493 latestTurn: thread.latestTurn,
494 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
495 branch: thread.branch,
496 worktreePath: thread.worktreePath,
497 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
498 turnId: checkpoint.turnId,
499 completedAt: checkpoint.completedAt,
500 status: checkpoint.status,
501 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
502 checkpointTurnCount: checkpoint.checkpointTurnCount,
503 checkpointRef: checkpoint.checkpointRef,
504 files: checkpoint.files.map((file) => ({ ...file })),
505 })),
506 activities: thread.activities.map((activity) => ({ ...activity })),
507 });
508 });
509 return {
510 ...state,
511 projects,
512 threads,
513 threadsHydrated: true,
514 };
515}
576516
577 case "MARK_THREAD_UNREAD": {
517function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?: string): AppState {
518 const nextVisitedAt = visitedAt ?? new Date().toISOString();
519 const visitedAtMs = Date.parse(nextVisitedAt);
520 return {
521 ...state,
522 threads: updateThread(state.threads, threadId, (thread) => {
523 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
524 if (
525 Number.isFinite(previousVisitedAtMs) &&
526 Number.isFinite(visitedAtMs) &&
527 previousVisitedAtMs >= visitedAtMs
528 ) {
529 return thread;
530 }
578531 return {
579 ...state,
580 threads: updateThread(state.threads, action.threadId, (thread) => {
581 if (!thread.latestTurn?.completedAt) {
582 return thread;
583 }
584 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
585 if (Number.isNaN(latestTurnCompletedAtMs)) {
586 return thread;
587 }
588 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
589 if (thread.lastVisitedAt === unreadVisitedAt) {
590 return thread;
591 }
592 return {
593 ...thread,
594 lastVisitedAt: unreadVisitedAt,
595 };
596 }),
532 ...thread,
533 lastVisitedAt: nextVisitedAt,
597534 };
598 }
535 }),
536 };
537}
599538
600 case "TOGGLE_PROJECT":
539function markThreadUnreadState(state: AppState, threadId: ThreadId): AppState {
540 return {
541 ...state,
542 threads: updateThread(state.threads, threadId, (thread) => {
543 if (!thread.latestTurn?.completedAt) {
544 return thread;
545 }
546 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
547 if (Number.isNaN(latestTurnCompletedAtMs)) {
548 return thread;
549 }
550 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
551 if (thread.lastVisitedAt === unreadVisitedAt) {
552 return thread;
553 }
601554 return {
602 ...state,
603 projects: state.projects.map((p) =>
604 p.id === action.projectId ? { ...p, expanded: !p.expanded } : p,
605 ),
555 ...thread,
556 lastVisitedAt: unreadVisitedAt,
606557 };
558 }),
559 };
560}
607561
608 case "SET_THREAD_TERMINAL_ACTIVITY":
609 return {
610 ...state,
611 threads: updateThread(state.threads, action.threadId, (thread) =>
612 setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess),
613 ),
614 };
562function toggleProjectState(state: AppState, projectId: Project["id"]): AppState {
563 return {
564 ...state,
565 projects: state.projects.map((project) =>
566 project.id === projectId ? { ...project, expanded: !project.expanded } : project,
567 ),
568 };
569}
615570
616 case "SET_PROJECT_EXPANDED":
617 return {
618 ...state,
619 projects: state.projects.map((p) =>
620 p.id === action.projectId ? { ...p, expanded: action.expanded } : p,
621 ),
622 };
571function setThreadTerminalActivityState(
572 state: AppState,
573 threadId: ThreadId,
574 terminalId: string,
575 hasRunningSubprocess: boolean,
576): AppState {
577 return {
578 ...state,
579 threads: updateThread(state.threads, threadId, (thread) =>
580 setThreadTerminalActivity(thread, terminalId, hasRunningSubprocess),
581 ),
582 };
583}
623584
624 case "TOGGLE_THREAD_TERMINAL":
625 return {
626 ...state,
627 threads: updateThread(state.threads, action.threadId, (t) => ({
628 ...t,
629 terminalOpen: !t.terminalOpen,
630 })),
631 };
585function setProjectExpandedState(
586 state: AppState,
587 projectId: Project["id"],
588 expanded: boolean,
589): AppState {
590 return {
591 ...state,
592 projects: state.projects.map((project) =>
593 project.id === projectId ? { ...project, expanded } : project,
594 ),
595 };
596}
632597
633 case "SET_THREAD_TERMINAL_OPEN":
634 return {
635 ...state,
636 threads: updateThread(state.threads, action.threadId, (t) => ({
637 ...t,
638 terminalOpen: action.open,
639 })),
640 };
598function toggleThreadTerminalState(state: AppState, threadId: ThreadId): AppState {
599 return {
600 ...state,
601 threads: updateThread(state.threads, threadId, (thread) => ({
602 ...thread,
603 terminalOpen: !thread.terminalOpen,
604 })),
605 };
606}
641607
642 case "SET_THREAD_TERMINAL_HEIGHT":
643 return {
644 ...state,
645 threads: updateThread(state.threads, action.threadId, (t) => ({
646 ...t,
647 terminalHeight: action.height,
648 })),
649 };
608function setThreadTerminalOpenState(state: AppState, threadId: ThreadId, open: boolean): AppState {
609 return {
610 ...state,
611 threads: updateThread(state.threads, threadId, (thread) => ({
612 ...thread,
613 terminalOpen: open,
614 })),
615 };
616}
650617
651 case "SPLIT_THREAD_TERMINAL":
652 return {
653 ...state,
654 threads: updateThread(state.threads, action.threadId, (thread) => {
655 const normalizedThread = normalizeThreadTerminals(thread);
656 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
657 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
658 return normalizedThread;
659 }
660 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
661 ? normalizedThread.terminalIds
662 : [...normalizedThread.terminalIds, action.terminalId];
663 const terminalGroups = normalizedThread.terminalGroups.map((group) => ({
664 ...group,
665 terminalIds: [...group.terminalIds],
666 }));
667 let activeGroupIndex = terminalGroups.findIndex(
668 (group) => group.id === normalizedThread.activeTerminalGroupId,
669 );
670 if (activeGroupIndex < 0) {
671 activeGroupIndex = findGroupIndexByTerminalId(
672 terminalGroups,
673 normalizedThread.activeTerminalId,
674 );
675 }
676 if (activeGroupIndex < 0) {
677 terminalGroups.push({
678 id: fallbackGroupId(normalizedThread.activeTerminalId),
679 terminalIds: [normalizedThread.activeTerminalId],
680 });
681 activeGroupIndex = terminalGroups.length - 1;
682 }
618function setThreadTerminalHeightState(
619 state: AppState,
620 threadId: ThreadId,
621 height: number,
622): AppState {
623 return {
624 ...state,
625 threads: updateThread(state.threads, threadId, (thread) => ({
626 ...thread,
627 terminalHeight: height,
628 })),
629 };
630}
683631
684 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId);
685 if (existingGroupIndex >= 0) {
686 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
687 existingGroupIndex
688 ]!.terminalIds.filter((id) => id !== action.terminalId);
689 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
690 terminalGroups.splice(existingGroupIndex, 1);
691 if (existingGroupIndex < activeGroupIndex) {
692 activeGroupIndex -= 1;
693 }
694 }
695 }
632function splitThreadTerminalState(
633 state: AppState,
634 threadId: ThreadId,
635 terminalId: string,
636): AppState {
637 return {
638 ...state,
639 threads: updateThread(state.threads, threadId, (thread) => {
640 const normalizedThread = normalizeThreadTerminals(thread);
641 const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId);
642 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
643 return normalizedThread;
644 }
645 const terminalIds = normalizedThread.terminalIds.includes(terminalId)
646 ? normalizedThread.terminalIds
647 : [...normalizedThread.terminalIds, terminalId];
648 const terminalGroups = normalizedThread.terminalGroups.map((group) => ({
649 ...group,
650 terminalIds: [...group.terminalIds],
651 }));
652 let activeGroupIndex = terminalGroups.findIndex(
653 (group) => group.id === normalizedThread.activeTerminalGroupId,
654 );
655 if (activeGroupIndex < 0) {
656 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalizedThread.activeTerminalId);
657 }
658 if (activeGroupIndex < 0) {
659 terminalGroups.push({
660 id: fallbackGroupId(normalizedThread.activeTerminalId),
661 terminalIds: [normalizedThread.activeTerminalId],
662 });
663 activeGroupIndex = terminalGroups.length - 1;
664 }
696665
697 const destinationGroup = terminalGroups[activeGroupIndex];
698 if (!destinationGroup) {
699 return normalizedThread;
700 }
701 if (!destinationGroup.terminalIds.includes(action.terminalId)) {
702 const anchorIndex = destinationGroup.terminalIds.indexOf(
703 normalizedThread.activeTerminalId,
704 );
705 if (anchorIndex >= 0) {
706 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);
707 } else {
708 destinationGroup.terminalIds.push(action.terminalId);
709 }
666 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
667 if (existingGroupIndex >= 0) {
668 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
669 (id) => id !== terminalId,
670 );
671 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
672 terminalGroups.splice(existingGroupIndex, 1);
673 if (existingGroupIndex < activeGroupIndex) {
674 activeGroupIndex -= 1;
710675 }
711 return normalizeThreadTerminals({
712 ...normalizedThread,
713 terminalIds,
714 activeTerminalId: action.terminalId,
715 activeTerminalGroupId: destinationGroup.id,
716 terminalGroups,
717 });
718 }),
719 };
676 }
677 }
720678
721 case "NEW_THREAD_TERMINAL":
722 return {
723 ...state,
724 threads: updateThread(state.threads, action.threadId, (thread) => {
725 const normalizedThread = normalizeThreadTerminals(thread);
726 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
727 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
728 return normalizedThread;
729 }
730 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
731 ? normalizedThread.terminalIds
732 : [...normalizedThread.terminalIds, action.terminalId];
733 const terminalGroups = normalizedThread.terminalGroups
734 .map((group) => ({
735 ...group,
736 terminalIds: group.terminalIds.filter((id) => id !== action.terminalId),
737 }))
738 .filter((group) => group.terminalIds.length > 0);
739 const nextGroupId = fallbackGroupId(action.terminalId);
740 terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] });
741
742 return normalizeThreadTerminals({
743 ...normalizedThread,
744 terminalIds,
745 activeTerminalId: action.terminalId,
746 activeTerminalGroupId: nextGroupId,
747 terminalGroups,
748 });
749 }),
750 };
679 const destinationGroup = terminalGroups[activeGroupIndex];
680 if (!destinationGroup) {
681 return normalizedThread;
682 }
683 if (!destinationGroup.terminalIds.includes(terminalId)) {
684 const anchorIndex = destinationGroup.terminalIds.indexOf(normalizedThread.activeTerminalId);
685 if (anchorIndex >= 0) {
686 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
687 } else {
688 destinationGroup.terminalIds.push(terminalId);
689 }
690 }
691 return normalizeThreadTerminals({
692 ...normalizedThread,
693 terminalIds,
694 activeTerminalId: terminalId,
695 activeTerminalGroupId: destinationGroup.id,
696 terminalGroups,
697 });
698 }),
699 };
700}
751701
752 case "SET_THREAD_ACTIVE_TERMINAL":
753 return {
754 ...state,
755 threads: updateThread(state.threads, action.threadId, (thread) => {
756 const normalizedThread = normalizeThreadTerminals(thread);
757 if (!normalizedThread.terminalIds.includes(action.terminalId)) {
758 return thread;
759 }
760 const nextActiveGroupIndex = findGroupIndexByTerminalId(
761 normalizedThread.terminalGroups,
762 action.terminalId,
763 );
764 const activeTerminalGroupId =
765 nextActiveGroupIndex >= 0
766 ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ??
767 normalizedThread.activeTerminalGroupId)
768 : normalizedThread.activeTerminalGroupId;
769 return normalizeThreadTerminals({
770 ...normalizedThread,
771 activeTerminalId: action.terminalId,
772 activeTerminalGroupId,
773 });
774 }),
775 };
702function newThreadTerminalState(
703 state: AppState,
704 threadId: ThreadId,
705 terminalId: string,
706): AppState {
707 return {
708 ...state,
709 threads: updateThread(state.threads, threadId, (thread) => {
710 const normalizedThread = normalizeThreadTerminals(thread);
711 const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId);
712 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
713 return normalizedThread;
714 }
715 const terminalIds = normalizedThread.terminalIds.includes(terminalId)
716 ? normalizedThread.terminalIds
717 : [...normalizedThread.terminalIds, terminalId];
718 const terminalGroups = normalizedThread.terminalGroups
719 .map((group) => ({
720 ...group,
721 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
722 }))
723 .filter((group) => group.terminalIds.length > 0);
724 const nextGroupId = fallbackGroupId(terminalId);
725 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
726
727 return normalizeThreadTerminals({
728 ...normalizedThread,
729 terminalIds,
730 activeTerminalId: terminalId,
731 activeTerminalGroupId: nextGroupId,
732 terminalGroups,
733 });
734 }),
735 };
736}
776737
777 case "CLOSE_THREAD_TERMINAL":
778 return {
779 ...state,
780 threads: updateThread(state.threads, action.threadId, (thread) =>
781 closeThreadTerminal(thread, action.terminalId),
782 ),
783 };
738function setThreadActiveTerminalState(
739 state: AppState,
740 threadId: ThreadId,
741 terminalId: string,
742): AppState {
743 return {
744 ...state,
745 threads: updateThread(state.threads, threadId, (thread) => {
746 const normalizedThread = normalizeThreadTerminals(thread);
747 if (!normalizedThread.terminalIds.includes(terminalId)) {
748 return thread;
749 }
750 const nextActiveGroupIndex = findGroupIndexByTerminalId(
751 normalizedThread.terminalGroups,
752 terminalId,
753 );
754 const activeTerminalGroupId =
755 nextActiveGroupIndex >= 0
756 ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ??
757 normalizedThread.activeTerminalGroupId)
758 : normalizedThread.activeTerminalGroupId;
759 return normalizeThreadTerminals({
760 ...normalizedThread,
761 activeTerminalId: terminalId,
762 activeTerminalGroupId,
763 });
764 }),
765 };
766}
784767
785 case "SET_ERROR":
786 return {
787 ...state,
788 threads: updateThread(state.threads, action.threadId, (t) => ({
789 ...t,
790 error: action.error,
791 })),
792 };
768function closeThreadTerminalState(
769 state: AppState,
770 threadId: ThreadId,
771 terminalId: string,
772): AppState {
773 return {
774 ...state,
775 threads: updateThread(state.threads, threadId, (thread) => closeThreadTerminal(thread, terminalId)),
776 };
777}
793778
794 case "SET_THREAD_BRANCH": {
795 return {
796 ...state,
797 threads: updateThread(state.threads, action.threadId, (t) => {
798 // When the effective cwd changes (worktreePath differs), the old
799 // session is no longer valid — clear it so ensureSession creates a
800 // new one with the correct cwd on the next message.
801 const cwdChanged = t.worktreePath !== action.worktreePath;
802 return {
803 ...t,
804 branch: action.branch,
805 worktreePath: action.worktreePath,
806 ...(cwdChanged ? { session: null } : {}),
807 };
808 }),
809 };
810 }
779function setThreadErrorState(
780 state: AppState,
781 threadId: ThreadId,
782 error: string | null,
783): AppState {
784 return {
785 ...state,
786 threads: updateThread(state.threads, threadId, (thread) => ({
787 ...thread,
788 error,
789 })),
790 };
791}
811792
812 case "SET_RUNTIME_MODE":
793function setThreadBranchState(
794 state: AppState,
795 threadId: ThreadId,
796 branch: string | null,
797 worktreePath: string | null,
798): AppState {
799 return {
800 ...state,
801 threads: updateThread(state.threads, threadId, (thread) => {
802 // When the effective cwd changes (worktreePath differs), the old
803 // session is no longer valid — clear it so ensureSession creates a
804 // new one with the correct cwd on the next message.
805 const cwdChanged = thread.worktreePath !== worktreePath;
813806 return {
814 ...state,
815 runtimeMode: action.mode,
807 ...thread,
808 branch,
809 worktreePath,
810 ...(cwdChanged ? { session: null } : {}),
816811 };
812 }),
813 };
814}
817815
818 default:
819 return state;
820 }
816function setRuntimeModeState(state: AppState, mode: RuntimeMode): AppState {
817 return {
818 ...state,
819 runtimeMode: mode,
820 };
821821}
822822
823823function toAppState(store: AppStore): AppState {
......@@ -829,54 +829,52 @@ function toAppState(store: AppStore): AppState {
829829 };
830830}
831831
832export const useStore = create<AppStore>((set, get) => {
833 const applyAction = (action: Action): void => {
834 const currentState = toAppState(get());
835 const nextState = reducer(currentState, action);
836 if (nextState === currentState) {
837 return;
838 }
839 persistState(nextState);
840 set(nextState);
832export const useStore = create<AppStore>((set) => {
833 const setAppState = (updater: (state: AppState) => AppState): void => {
834 set((store) => {
835 const currentState = toAppState(store);
836 const nextState = updater(currentState);
837 if (nextState === currentState) {
838 return store;
839 }
840 persistState(nextState);
841 return nextState;
842 });
841843 };
842844
843845 return {
844846 ...readPersistedState(),
845 syncServerReadModel: (readModel) => applyAction({ type: "SYNC_SERVER_READ_MODEL", readModel }),
847 syncServerReadModel: (readModel) =>
848 setAppState((state) => syncServerReadModelState(state, readModel)),
846849 markThreadVisited: (threadId, visitedAt) =>
847 applyAction({
848 type: "MARK_THREAD_VISITED",
849 threadId,
850 ...(visitedAt === undefined ? {} : { visitedAt }),
851 }),
852 markThreadUnread: (threadId) => applyAction({ type: "MARK_THREAD_UNREAD", threadId }),
853 toggleProject: (projectId) => applyAction({ type: "TOGGLE_PROJECT", projectId }),
850 setAppState((state) => markThreadVisitedState(state, threadId, visitedAt)),
851 markThreadUnread: (threadId) => setAppState((state) => markThreadUnreadState(state, threadId)),
852 toggleProject: (projectId) => setAppState((state) => toggleProjectState(state, projectId)),
854853 setThreadTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
855 applyAction({
856 type: "SET_THREAD_TERMINAL_ACTIVITY",
857 threadId,
858 terminalId,
859 hasRunningSubprocess,
860 }),
854 setAppState((state) =>
855 setThreadTerminalActivityState(state, threadId, terminalId, hasRunningSubprocess),
856 ),
861857 setProjectExpanded: (projectId, expanded) =>
862 applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),
863 toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),
858 setAppState((state) => setProjectExpandedState(state, projectId, expanded)),
859 toggleThreadTerminal: (threadId) =>
860 setAppState((state) => toggleThreadTerminalState(state, threadId)),
864861 setThreadTerminalOpen: (threadId, open) =>
865 applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),
862 setAppState((state) => setThreadTerminalOpenState(state, threadId, open)),
866863 setThreadTerminalHeight: (threadId, height) =>
867 applyAction({ type: "SET_THREAD_TERMINAL_HEIGHT", threadId, height }),
864 setAppState((state) => setThreadTerminalHeightState(state, threadId, height)),
868865 splitThreadTerminal: (threadId, terminalId) =>
869 applyAction({ type: "SPLIT_THREAD_TERMINAL", threadId, terminalId }),
866 setAppState((state) => splitThreadTerminalState(state, threadId, terminalId)),
870867 newThreadTerminal: (threadId, terminalId) =>
871 applyAction({ type: "NEW_THREAD_TERMINAL", threadId, terminalId }),
868 setAppState((state) => newThreadTerminalState(state, threadId, terminalId)),
872869 setThreadActiveTerminal: (threadId, terminalId) =>
873 applyAction({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId, terminalId }),
870 setAppState((state) => setThreadActiveTerminalState(state, threadId, terminalId)),
874871 closeThreadTerminal: (threadId, terminalId) =>
875 applyAction({ type: "CLOSE_THREAD_TERMINAL", threadId, terminalId }),
876 setThreadError: (threadId, error) => applyAction({ type: "SET_ERROR", threadId, error }),
872 setAppState((state) => closeThreadTerminalState(state, threadId, terminalId)),
873 setThreadError: (threadId, error) =>
874 setAppState((state) => setThreadErrorState(state, threadId, error)),
877875 setThreadBranch: (threadId, branch, worktreePath) =>
878 applyAction({ type: "SET_THREAD_BRANCH", threadId, branch, worktreePath }),
879 setRuntimeMode: (mode) => applyAction({ type: "SET_RUNTIME_MODE", mode }),
876 setAppState((state) => setThreadBranchState(state, threadId, branch, worktreePath)),
877 setRuntimeMode: (mode) => setAppState((state) => setRuntimeModeState(state, mode)),
880878 };
881879});
882880