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 @@...@@ -1,7 +1,7 @@
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";1import { 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";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
66
7function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
...@@ -54,7 +54,16 @@ function makeState(thread: Thread): AppState {...@@ -54,7 +54,16 @@ function makeState(thread: Thread): AppState {
54 };54 };
55}55}
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
58 it("marks a completed thread as unread by moving lastVisitedAt before completion", () => {67 it("marks a completed thread as unread by moving lastVisitedAt before completion", () => {
59 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";68 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
60 const initialState = makeState(69 const initialState = makeState(
...@@ -71,12 +80,10 @@ describe("store reducer", () => {...@@ -71,12 +80,10 @@ describe("store reducer", () => {
71 }),80 }),
72 );81 );
7382
74 const next = reducer(initialState, {83 useStore.setState(initialState);
75 type: "MARK_THREAD_UNREAD",84 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
76 threadId: ThreadId.makeUnsafe("thread-1"),
77 });
7885
79 const updatedThread = next.threads[0];86 const updatedThread = useStore.getState().threads[0];
80 expect(updatedThread).toBeDefined();87 expect(updatedThread).toBeDefined();
81 expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z");88 expect(updatedThread?.lastVisitedAt).toBe("2026-02-25T12:29:59.999Z");
82 expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan(89 expect(Date.parse(updatedThread?.lastVisitedAt ?? "")).toBeLessThan(
...@@ -92,16 +99,23 @@ describe("store reducer", () => {...@@ -92,16 +99,23 @@ describe("store reducer", () => {
92 }),99 }),
93 );100 );
94101
95 const next = reducer(initialState, {102 useStore.setState(initialState);
96 type: "MARK_THREAD_UNREAD",103 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
97 threadId: ThreadId.makeUnsafe("thread-1"),
98 });
99104
100 expect(next).toEqual(initialState);105 expect(useStore.getState().threads[0]?.lastVisitedAt).toBe("2026-02-25T12:35:00.000Z");
101 });106 });
102});107});
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
105 it("adds a terminal to runningTerminalIds when subprocess activity starts", () => {119 it("adds a terminal to runningTerminalIds when subprocess activity starts", () => {
106 const state = makeState(120 const state = makeState(
107 makeThread({121 makeThread({
...@@ -114,14 +128,12 @@ describe("store terminal activity reducer", () => {...@@ -114,14 +128,12 @@ describe("store terminal activity reducer", () => {
114 ],128 ],
115 }),129 }),
116 );130 );
117 const next = reducer(state, {131 useStore.setState(state);
118 type: "SET_THREAD_TERMINAL_ACTIVITY",132 useStore
119 threadId: ThreadId.makeUnsafe("thread-1"),133 .getState()
120 terminalId: "alt",134 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", true);
121 hasRunningSubprocess: true,
122 });
123135
124 expect(next.threads[0]?.runningTerminalIds).toEqual(["alt"]);136 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual(["alt"]);
125 });137 });
126138
127 it("removes a terminal from runningTerminalIds when subprocess activity stops", () => {139 it("removes a terminal from runningTerminalIds when subprocess activity stops", () => {
...@@ -137,25 +149,21 @@ describe("store terminal activity reducer", () => {...@@ -137,25 +149,21 @@ describe("store terminal activity reducer", () => {
137 runningTerminalIds: ["alt"],149 runningTerminalIds: ["alt"],
138 }),150 }),
139 );151 );
140 const next = reducer(state, {152 useStore.setState(state);
141 type: "SET_THREAD_TERMINAL_ACTIVITY",153 useStore
142 threadId: ThreadId.makeUnsafe("thread-1"),154 .getState()
143 terminalId: "alt",155 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", false);
144 hasRunningSubprocess: false,
145 });
146156
147 expect(next.threads[0]?.runningTerminalIds).toEqual([]);157 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
148 });158 });
149159
150 it("ignores activity events for unknown terminal ids", () => {160 it("ignores activity events for unknown terminal ids", () => {
151 const state = makeState(makeThread());161 const state = makeState(makeThread());
152 const next = reducer(state, {162 useStore.setState(state);
153 type: "SET_THREAD_TERMINAL_ACTIVITY",163 useStore
154 threadId: ThreadId.makeUnsafe("thread-1"),164 .getState()
155 terminalId: "missing",165 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "missing", true);
156 hasRunningSubprocess: true,
157 });
158166
159 expect(next.threads[0]?.runningTerminalIds).toEqual([]);167 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
160 });168 });
161});169});
apps/web/src/store.ts+399-401
...@@ -21,36 +21,6 @@ import {...@@ -21,36 +21,6 @@ import {
21 type ThreadTerminalGroup,21 type ThreadTerminalGroup,
22} from "./types";22} 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
54// ── State ────────────────────────────────────────────────────────────24// ── State ────────────────────────────────────────────────────────────
5525
56export interface AppState {26export interface AppState {
...@@ -455,369 +425,399 @@ function setThreadTerminalActivity(...@@ -455,369 +425,399 @@ function setThreadTerminalActivity(
455 });425 });
456}426}
457427
458// ── Reducer ──────────────────────────────────────────────────────────428function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState {
459429 const projects = mapProjectsFromReadModel(
460export function reducer(state: AppState, action: Action): AppState {430 readModel.projects.filter((project) => project.deletedAt === null),
461 switch (action.type) {431 state.projects,
462 case "SYNC_SERVER_READ_MODEL": {432 );
463 const projects = mapProjectsFromReadModel(433 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));
464 action.readModel.projects.filter((project) => project.deletedAt === null),434 const threads = readModel.threads
465 state.projects,435 .filter((thread) => thread.deletedAt === null)
466 );436 .map((thread) => {
467 const existingThreadById = new Map(437 const existing = existingThreadById.get(thread.id);
468 state.threads.map((thread) => [thread.id, thread] as const),438
469 );439 return normalizeThreadTerminals({
470 const threads = action.readModel.threads440 id: thread.id,
471 .filter((thread) => thread.deletedAt === null)441 codexThreadId: thread.session?.providerThreadId ?? null,
472 .map((thread) => {442 projectId: thread.projectId,
473 const existing = existingThreadById.get(thread.id);443 title: thread.title,
474444 model: resolveModelSlug(thread.model),
475 return normalizeThreadTerminals({445 terminalOpen: existing?.terminalOpen ?? false,
476 id: thread.id,446 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
477 codexThreadId: thread.session?.providerThreadId ?? null,447 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
478 projectId: thread.projectId,448 runningTerminalIds: existing?.runningTerminalIds ?? [],
479 title: thread.title,449 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
480 model: resolveModelSlug(thread.model),450 terminalGroups: existing?.terminalGroups ?? [
481 terminalOpen: existing?.terminalOpen ?? false,451 {
482 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,452 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
483 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],453 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
484 runningTerminalIds: existing?.runningTerminalIds ?? [],454 },
485 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,455 ],
486 terminalGroups: existing?.terminalGroups ?? [456 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
487 {457 session: thread.session
488 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,458 ? {
489 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],459 sessionId:
490 },460 thread.session.providerSessionId ?? ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
491 ],461 provider: toLegacyProvider(thread.session.providerName),
492 activeTerminalGroupId:462 status: toLegacySessionStatus(thread.session.status),
493 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,463 orchestrationStatus: thread.session.status,
494 session: thread.session464 threadId: thread.session.providerThreadId,
495 ? {465 activeTurnId: thread.session.activeTurnId ?? undefined,
496 sessionId:466 createdAt: thread.session.updatedAt,
497 thread.session.providerSessionId ??467 updatedAt: thread.session.updatedAt,
498 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),468 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
499 provider: toLegacyProvider(thread.session.providerName),469 }
500 status: toLegacySessionStatus(thread.session.status),470 : null,
501 orchestrationStatus: thread.session.status,471 messages: thread.messages.map((message) => {
502 threadId: thread.session.providerThreadId,472 const attachments = message.attachments?.map((attachment) => ({
503 activeTurnId: thread.session.activeTurnId ?? undefined,473 type: "image" as const,
504 createdAt: thread.session.updatedAt,474 id: attachment.id,
505 updatedAt: thread.session.updatedAt,475 name: attachment.name,
506 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),476 mimeType: attachment.mimeType,
507 }477 sizeBytes: attachment.sizeBytes,
508 : null,478 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
509 messages: thread.messages.map((message) => {479 }));
510 const attachments = message.attachments?.map((attachment) => ({480 const normalizedMessage: ChatMessage = {
511 type: "image" as const,481 id: message.id,
512 id: attachment.id,482 role: message.role,
513 name: attachment.name,483 text: message.text,
514 mimeType: attachment.mimeType,484 createdAt: message.createdAt,
515 sizeBytes: attachment.sizeBytes,485 streaming: message.streaming,
516 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),486 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
517 }));487 ...(attachments && attachments.length > 0 ? { attachments } : {}),
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,
572 };488 };
489 return normalizedMessage;
573 }),490 }),
574 };491 error: thread.session?.lastError ?? null,
575 }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 }
578 return {531 return {
579 ...state,532 ...thread,
580 threads: updateThread(state.threads, action.threadId, (thread) => {533 lastVisitedAt: nextVisitedAt,
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 }),
597 };534 };
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 }
601 return {554 return {
602 ...state,555 ...thread,
603 projects: state.projects.map((p) =>556 lastVisitedAt: unreadVisitedAt,
604 p.id === action.projectId ? { ...p, expanded: !p.expanded } : p,
605 ),
606 };557 };
558 }),
559 };
560}
607561
608 case "SET_THREAD_TERMINAL_ACTIVITY":562function toggleProjectState(state: AppState, projectId: Project["id"]): AppState {
609 return {563 return {
610 ...state,564 ...state,
611 threads: updateThread(state.threads, action.threadId, (thread) =>565 projects: state.projects.map((project) =>
612 setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess),566 project.id === projectId ? { ...project, expanded: !project.expanded } : project,
613 ),567 ),
614 };568 };
569}
615570
616 case "SET_PROJECT_EXPANDED":571function setThreadTerminalActivityState(
617 return {572 state: AppState,
618 ...state,573 threadId: ThreadId,
619 projects: state.projects.map((p) =>574 terminalId: string,
620 p.id === action.projectId ? { ...p, expanded: action.expanded } : p,575 hasRunningSubprocess: boolean,
621 ),576): AppState {
622 };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":585function setProjectExpandedState(
625 return {586 state: AppState,
626 ...state,587 projectId: Project["id"],
627 threads: updateThread(state.threads, action.threadId, (t) => ({588 expanded: boolean,
628 ...t,589): AppState {
629 terminalOpen: !t.terminalOpen,590 return {
630 })),591 ...state,
631 };592 projects: state.projects.map((project) =>
593 project.id === projectId ? { ...project, expanded } : project,
594 ),
595 };
596}
632597
633 case "SET_THREAD_TERMINAL_OPEN":598function toggleThreadTerminalState(state: AppState, threadId: ThreadId): AppState {
634 return {599 return {
635 ...state,600 ...state,
636 threads: updateThread(state.threads, action.threadId, (t) => ({601 threads: updateThread(state.threads, threadId, (thread) => ({
637 ...t,602 ...thread,
638 terminalOpen: action.open,603 terminalOpen: !thread.terminalOpen,
639 })),604 })),
640 };605 };
606}
641607
642 case "SET_THREAD_TERMINAL_HEIGHT":608function setThreadTerminalOpenState(state: AppState, threadId: ThreadId, open: boolean): AppState {
643 return {609 return {
644 ...state,610 ...state,
645 threads: updateThread(state.threads, action.threadId, (t) => ({611 threads: updateThread(state.threads, threadId, (thread) => ({
646 ...t,612 ...thread,
647 terminalHeight: action.height,613 terminalOpen: open,
648 })),614 })),
649 };615 };
616}
650617
651 case "SPLIT_THREAD_TERMINAL":618function setThreadTerminalHeightState(
652 return {619 state: AppState,
653 ...state,620 threadId: ThreadId,
654 threads: updateThread(state.threads, action.threadId, (thread) => {621 height: number,
655 const normalizedThread = normalizeThreadTerminals(thread);622): AppState {
656 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);623 return {
657 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {624 ...state,
658 return normalizedThread;625 threads: updateThread(state.threads, threadId, (thread) => ({
659 }626 ...thread,
660 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)627 terminalHeight: height,
661 ? normalizedThread.terminalIds628 })),
662 : [...normalizedThread.terminalIds, action.terminalId];629 };
663 const terminalGroups = normalizedThread.terminalGroups.map((group) => ({630}
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 }
683631
684 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId);632function splitThreadTerminalState(
685 if (existingGroupIndex >= 0) {633 state: AppState,
686 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[634 threadId: ThreadId,
687 existingGroupIndex635 terminalId: string,
688 ]!.terminalIds.filter((id) => id !== action.terminalId);636): AppState {
689 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {637 return {
690 terminalGroups.splice(existingGroupIndex, 1);638 ...state,
691 if (existingGroupIndex < activeGroupIndex) {639 threads: updateThread(state.threads, threadId, (thread) => {
692 activeGroupIndex -= 1;640 const normalizedThread = normalizeThreadTerminals(thread);
693 }641 const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId);
694 }642 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
695 }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];666 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
698 if (!destinationGroup) {667 if (existingGroupIndex >= 0) {
699 return normalizedThread;668 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
700 }669 (id) => id !== terminalId,
701 if (!destinationGroup.terminalIds.includes(action.terminalId)) {670 );
702 const anchorIndex = destinationGroup.terminalIds.indexOf(671 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
703 normalizedThread.activeTerminalId,672 terminalGroups.splice(existingGroupIndex, 1);
704 );673 if (existingGroupIndex < activeGroupIndex) {
705 if (anchorIndex >= 0) {674 activeGroupIndex -= 1;
706 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);
707 } else {
708 destinationGroup.terminalIds.push(action.terminalId);
709 }
710 }675 }
711 return normalizeThreadTerminals({676 }
712 ...normalizedThread,677 }
713 terminalIds,
714 activeTerminalId: action.terminalId,
715 activeTerminalGroupId: destinationGroup.id,
716 terminalGroups,
717 });
718 }),
719 };
720678
721 case "NEW_THREAD_TERMINAL":679 const destinationGroup = terminalGroups[activeGroupIndex];
722 return {680 if (!destinationGroup) {
723 ...state,681 return normalizedThread;
724 threads: updateThread(state.threads, action.threadId, (thread) => {682 }
725 const normalizedThread = normalizeThreadTerminals(thread);683 if (!destinationGroup.terminalIds.includes(terminalId)) {
726 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);684 const anchorIndex = destinationGroup.terminalIds.indexOf(normalizedThread.activeTerminalId);
727 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {685 if (anchorIndex >= 0) {
728 return normalizedThread;686 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
729 }687 } else {
730 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)688 destinationGroup.terminalIds.push(terminalId);
731 ? normalizedThread.terminalIds689 }
732 : [...normalizedThread.terminalIds, action.terminalId];690 }
733 const terminalGroups = normalizedThread.terminalGroups691 return normalizeThreadTerminals({
734 .map((group) => ({692 ...normalizedThread,
735 ...group,693 terminalIds,
736 terminalIds: group.terminalIds.filter((id) => id !== action.terminalId),694 activeTerminalId: terminalId,
737 }))695 activeTerminalGroupId: destinationGroup.id,
738 .filter((group) => group.terminalIds.length > 0);696 terminalGroups,
739 const nextGroupId = fallbackGroupId(action.terminalId);697 });
740 terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] });698 }),
741699 };
742 return normalizeThreadTerminals({700}
743 ...normalizedThread,
744 terminalIds,
745 activeTerminalId: action.terminalId,
746 activeTerminalGroupId: nextGroupId,
747 terminalGroups,
748 });
749 }),
750 };
751701
752 case "SET_THREAD_ACTIVE_TERMINAL":702function newThreadTerminalState(
753 return {703 state: AppState,
754 ...state,704 threadId: ThreadId,
755 threads: updateThread(state.threads, action.threadId, (thread) => {705 terminalId: string,
756 const normalizedThread = normalizeThreadTerminals(thread);706): AppState {
757 if (!normalizedThread.terminalIds.includes(action.terminalId)) {707 return {
758 return thread;708 ...state,
759 }709 threads: updateThread(state.threads, threadId, (thread) => {
760 const nextActiveGroupIndex = findGroupIndexByTerminalId(710 const normalizedThread = normalizeThreadTerminals(thread);
761 normalizedThread.terminalGroups,711 const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId);
762 action.terminalId,712 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
763 );713 return normalizedThread;
764 const activeTerminalGroupId =714 }
765 nextActiveGroupIndex >= 0715 const terminalIds = normalizedThread.terminalIds.includes(terminalId)
766 ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ??716 ? normalizedThread.terminalIds
767 normalizedThread.activeTerminalGroupId)717 : [...normalizedThread.terminalIds, terminalId];
768 : normalizedThread.activeTerminalGroupId;718 const terminalGroups = normalizedThread.terminalGroups
769 return normalizeThreadTerminals({719 .map((group) => ({
770 ...normalizedThread,720 ...group,
771 activeTerminalId: action.terminalId,721 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
772 activeTerminalGroupId,722 }))
773 });723 .filter((group) => group.terminalIds.length > 0);
774 }),724 const nextGroupId = fallbackGroupId(terminalId);
775 };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":738function setThreadActiveTerminalState(
778 return {739 state: AppState,
779 ...state,740 threadId: ThreadId,
780 threads: updateThread(state.threads, action.threadId, (thread) =>741 terminalId: string,
781 closeThreadTerminal(thread, action.terminalId),742): AppState {
782 ),743 return {
783 };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":768function closeThreadTerminalState(
786 return {769 state: AppState,
787 ...state,770 threadId: ThreadId,
788 threads: updateThread(state.threads, action.threadId, (t) => ({771 terminalId: string,
789 ...t,772): AppState {
790 error: action.error,773 return {
791 })),774 ...state,
792 };775 threads: updateThread(state.threads, threadId, (thread) => closeThreadTerminal(thread, terminalId)),
776 };
777}
793778
794 case "SET_THREAD_BRANCH": {779function setThreadErrorState(
795 return {780 state: AppState,
796 ...state,781 threadId: ThreadId,
797 threads: updateThread(state.threads, action.threadId, (t) => {782 error: string | null,
798 // When the effective cwd changes (worktreePath differs), the old783): AppState {
799 // session is no longer valid — clear it so ensureSession creates a784 return {
800 // new one with the correct cwd on the next message.785 ...state,
801 const cwdChanged = t.worktreePath !== action.worktreePath;786 threads: updateThread(state.threads, threadId, (thread) => ({
802 return {787 ...thread,
803 ...t,788 error,
804 branch: action.branch,789 })),
805 worktreePath: action.worktreePath,790 };
806 ...(cwdChanged ? { session: null } : {}),791}
807 };
808 }),
809 };
810 }
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;
813 return {806 return {
814 ...state,807 ...thread,
815 runtimeMode: action.mode,808 branch,
809 worktreePath,
810 ...(cwdChanged ? { session: null } : {}),
816 };811 };
812 }),
813 };
814}
817815
818 default:816function setRuntimeModeState(state: AppState, mode: RuntimeMode): AppState {
819 return state;817 return {
820 }818 ...state,
819 runtimeMode: mode,
820 };
821}821}
822822
823function toAppState(store: AppStore): AppState {823function toAppState(store: AppStore): AppState {
...@@ -829,54 +829,52 @@ function toAppState(store: AppStore): AppState {...@@ -829,54 +829,52 @@ function toAppState(store: AppStore): AppState {
829 };829 };
830}830}
831831
832export const useStore = create<AppStore>((set, get) => {832export const useStore = create<AppStore>((set) => {
833 const applyAction = (action: Action): void => {833 const setAppState = (updater: (state: AppState) => AppState): void => {
834 const currentState = toAppState(get());834 set((store) => {
835 const nextState = reducer(currentState, action);835 const currentState = toAppState(store);
836 if (nextState === currentState) {836 const nextState = updater(currentState);
837 return;837 if (nextState === currentState) {
838 }838 return store;
839 persistState(nextState);839 }
840 set(nextState);840 persistState(nextState);
841 return nextState;
842 });
841 };843 };
842844
843 return {845 return {
844 ...readPersistedState(),846 ...readPersistedState(),
845 syncServerReadModel: (readModel) => applyAction({ type: "SYNC_SERVER_READ_MODEL", readModel }),847 syncServerReadModel: (readModel) =>
848 setAppState((state) => syncServerReadModelState(state, readModel)),
846 markThreadVisited: (threadId, visitedAt) =>849 markThreadVisited: (threadId, visitedAt) =>
847 applyAction({850 setAppState((state) => markThreadVisitedState(state, threadId, visitedAt)),
848 type: "MARK_THREAD_VISITED",851 markThreadUnread: (threadId) => setAppState((state) => markThreadUnreadState(state, threadId)),
849 threadId,852 toggleProject: (projectId) => setAppState((state) => toggleProjectState(state, projectId)),
850 ...(visitedAt === undefined ? {} : { visitedAt }),
851 }),
852 markThreadUnread: (threadId) => applyAction({ type: "MARK_THREAD_UNREAD", threadId }),
853 toggleProject: (projectId) => applyAction({ type: "TOGGLE_PROJECT", projectId }),
854 setThreadTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>853 setThreadTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
855 applyAction({854 setAppState((state) =>
856 type: "SET_THREAD_TERMINAL_ACTIVITY",855 setThreadTerminalActivityState(state, threadId, terminalId, hasRunningSubprocess),
857 threadId,856 ),
858 terminalId,
859 hasRunningSubprocess,
860 }),
861 setProjectExpanded: (projectId, expanded) =>857 setProjectExpanded: (projectId, expanded) =>
862 applyAction({ type: "SET_PROJECT_EXPANDED", projectId, expanded }),858 setAppState((state) => setProjectExpandedState(state, projectId, expanded)),
863 toggleThreadTerminal: (threadId) => applyAction({ type: "TOGGLE_THREAD_TERMINAL", threadId }),859 toggleThreadTerminal: (threadId) =>
860 setAppState((state) => toggleThreadTerminalState(state, threadId)),
864 setThreadTerminalOpen: (threadId, open) =>861 setThreadTerminalOpen: (threadId, open) =>
865 applyAction({ type: "SET_THREAD_TERMINAL_OPEN", threadId, open }),862 setAppState((state) => setThreadTerminalOpenState(state, threadId, open)),
866 setThreadTerminalHeight: (threadId, height) =>863 setThreadTerminalHeight: (threadId, height) =>
867 applyAction({ type: "SET_THREAD_TERMINAL_HEIGHT", threadId, height }),864 setAppState((state) => setThreadTerminalHeightState(state, threadId, height)),
868 splitThreadTerminal: (threadId, terminalId) =>865 splitThreadTerminal: (threadId, terminalId) =>
869 applyAction({ type: "SPLIT_THREAD_TERMINAL", threadId, terminalId }),866 setAppState((state) => splitThreadTerminalState(state, threadId, terminalId)),
870 newThreadTerminal: (threadId, terminalId) =>867 newThreadTerminal: (threadId, terminalId) =>
871 applyAction({ type: "NEW_THREAD_TERMINAL", threadId, terminalId }),868 setAppState((state) => newThreadTerminalState(state, threadId, terminalId)),
872 setThreadActiveTerminal: (threadId, terminalId) =>869 setThreadActiveTerminal: (threadId, terminalId) =>
873 applyAction({ type: "SET_THREAD_ACTIVE_TERMINAL", threadId, terminalId }),870 setAppState((state) => setThreadActiveTerminalState(state, threadId, terminalId)),
874 closeThreadTerminal: (threadId, terminalId) =>871 closeThreadTerminal: (threadId, terminalId) =>
875 applyAction({ type: "CLOSE_THREAD_TERMINAL", threadId, terminalId }),872 setAppState((state) => closeThreadTerminalState(state, threadId, terminalId)),
876 setThreadError: (threadId, error) => applyAction({ type: "SET_ERROR", threadId, error }),873 setThreadError: (threadId, error) =>
874 setAppState((state) => setThreadErrorState(state, threadId, error)),
877 setThreadBranch: (threadId, branch, worktreePath) =>875 setThreadBranch: (threadId, branch, worktreePath) =>
878 applyAction({ type: "SET_THREAD_BRANCH", threadId, branch, worktreePath }),876 setAppState((state) => setThreadBranchState(state, threadId, branch, worktreePath)),
879 setRuntimeMode: (mode) => applyAction({ type: "SET_RUNTIME_MODE", mode }),877 setRuntimeMode: (mode) => setAppState((state) => setRuntimeModeState(state, mode)),
880 };878 };
881});879});
882880