@brimveyn/aimux 1.21.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -1,9 +1,6 @@
1
- import {
2
- type AppAction,
3
- type AppState,
4
- type DiscoveredRepo,
5
- EMPTY_MULTI_REPO_STATE,
6
- } from '../types'
1
+ import type { AppAction } from '../actions'
2
+
3
+ import { type AppState, type DiscoveredRepo, EMPTY_MULTI_REPO_STATE } from '../types'
7
4
 
8
5
  /**
9
6
  * Compute the shortest distinguishing prefix for each non-root repo name.
@@ -0,0 +1,312 @@
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState } from '../types'
3
+
4
+ import { moveIdToIdPosition, orderProjectsForDisplay } from '../../ui/project-ordering'
5
+ import { restoreProjectState } from '../project-persistence'
6
+ import {
7
+ clearWorkspaceDone,
8
+ filterTabsForActiveWorkspace,
9
+ withActiveWorkspace,
10
+ } from '../project-workspaces'
11
+ import { filterProjects } from '../selectors'
12
+ // eslint-disable-next-line no-duplicate-imports
13
+ import { IDLE_WORKSPACE_ACTIVITY } from '../types'
14
+
15
+ const CLOSED_MODAL = {
16
+ editBuffer: null,
17
+ projectTargetId: null,
18
+ selectedIndex: 0,
19
+ type: null,
20
+ } as const
21
+
22
+ export function reduceProjectState(state: AppState, action: AppAction): AppState | null {
23
+ switch (action.type) {
24
+ case 'load-project': {
25
+ const snapshot =
26
+ action.projectSnapshot ??
27
+ state.projects.find((entry) => entry.id === action.projectId)?.projectSnapshot
28
+ // The project's activeWorkspaceId may have been patched right before
29
+ // this load (e.g. the cross-project branch of handleCycleSidebarItem
30
+ // sets it to the workspace the user just clicked). The snapshot's
31
+ // activeTabId still reflects the *last* workspace they were on, so
32
+ // honor the patched workspace by filtering the restored tab list.
33
+ const loadedProject = state.projects.find((entry) => entry.id === action.projectId)
34
+ const loadedWorkspaces = loadedProject?.workspaces ?? []
35
+ const restored = restoreProjectState(state, snapshot, {
36
+ forceDisconnected: action.forceDisconnected ?? true,
37
+ // Drop tabs bound to workspaces this project no longer owns so a stale
38
+ // id can't be caught by a later workspace delete (closing "another
39
+ // workspace's" tabs) and so corrupted catalogs self-heal on load. Only
40
+ // prune once the project has workspaces — an empty list means it hasn't
41
+ // been initialized yet, and pruning then would wrongly drop bound tabs.
42
+ validWorkspaceIds:
43
+ loadedWorkspaces.length > 0 ? new Set(loadedWorkspaces.map((w) => w.id)) : undefined,
44
+ })
45
+ const visible = filterTabsForActiveWorkspace(restored.tabs, loadedProject)
46
+ // Prefer the snapshot's tab; if it isn't visible under the active
47
+ // workspace (e.g. a multi-workspace project restored onto a different
48
+ // workspace), fall back to the last tab we viewed in that workspace,
49
+ // then to the first visible tab.
50
+ const rememberedForWorkspace =
51
+ loadedProject?.activeWorkspaceId != null && loadedProject.activeWorkspaceId !== ''
52
+ ? state.lastActiveTabByWorkspace[loadedProject.activeWorkspaceId]
53
+ : undefined
54
+ const snapshotTabVisible =
55
+ restored.activeTabId != null &&
56
+ restored.activeTabId !== '' &&
57
+ visible.some((t) => t.id === restored.activeTabId)
58
+ const rememberedTabVisible =
59
+ rememberedForWorkspace != null &&
60
+ rememberedForWorkspace !== '' &&
61
+ visible.some((t) => t.id === rememberedForWorkspace)
62
+ const fallbackTabId = rememberedTabVisible ? rememberedForWorkspace : (visible[0]?.id ?? null)
63
+ const activeTabId = snapshotTabVisible ? restored.activeTabId : fallbackTabId
64
+ return {
65
+ ...state,
66
+ ...restored,
67
+ activeTabId,
68
+ currentProjectId: action.projectId,
69
+ focusMode: 'navigation',
70
+ modal: CLOSED_MODAL,
71
+ projects: state.projects.map((entry) =>
72
+ entry.id === action.projectId
73
+ ? {
74
+ ...entry,
75
+ lastOpenedAt: new Date().toISOString(),
76
+ updatedAt: new Date().toISOString(),
77
+ }
78
+ : entry
79
+ ),
80
+ // Landing on a project lands on its active workspace — same rule as
81
+ // `set-active-workspace`: you have seen it, so the tick goes.
82
+ workspaceActivity: clearWorkspaceDone(
83
+ state.workspaceActivity,
84
+ loadedProject?.activeWorkspaceId
85
+ ),
86
+ }
87
+ }
88
+ case 'set-projects':
89
+ return { ...state, projects: action.projects }
90
+ case 'create-project-record':
91
+ return {
92
+ ...state,
93
+ currentProjectId: action.project.id,
94
+ focusMode: 'navigation',
95
+ modal: CLOSED_MODAL,
96
+ projects: [...state.projects, action.project],
97
+ }
98
+ case 'rename-project-record':
99
+ return {
100
+ ...state,
101
+ projects: state.projects.map((project) =>
102
+ project.id === action.projectId
103
+ ? { ...project, name: action.name, updatedAt: new Date().toISOString() }
104
+ : project
105
+ ),
106
+ }
107
+ case 'delete-project-record': {
108
+ const deletingCurrent = action.projectId === state.currentProjectId
109
+ const newProjects = state.projects.filter((project) => project.id !== action.projectId)
110
+ const nextStatuses = { ...state.projectStatuses }
111
+ delete nextStatuses[action.projectId]
112
+ if (action.openProjectPicker === true) {
113
+ const filteredNew = filterProjects(newProjects, state.modal.editBuffer)
114
+ const maxIndex = filteredNew.length
115
+ const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
116
+ return {
117
+ ...state,
118
+ activeTabId: deletingCurrent ? null : state.activeTabId,
119
+ currentProjectId: deletingCurrent ? null : state.currentProjectId,
120
+ focusMode: 'modal',
121
+ modal: {
122
+ editBuffer: null,
123
+ projectTargetId: null,
124
+ selectedIndex: clampedIndex,
125
+ type: 'project-picker',
126
+ },
127
+ projects: newProjects,
128
+ projectStatuses: nextStatuses,
129
+ tabs: deletingCurrent ? [] : state.tabs,
130
+ }
131
+ }
132
+ return {
133
+ ...state,
134
+ activeTabId: deletingCurrent ? null : state.activeTabId,
135
+ currentProjectId: deletingCurrent ? null : state.currentProjectId,
136
+ focusMode: deletingCurrent ? 'navigation' : state.focusMode,
137
+ modal: deletingCurrent ? CLOSED_MODAL : state.modal,
138
+ projects: newProjects,
139
+ projectStatuses: nextStatuses,
140
+ tabs: deletingCurrent ? [] : state.tabs,
141
+ }
142
+ }
143
+ case 'reorder-projects': {
144
+ const byId = new Map(state.projects.map((s) => [s.id, s]))
145
+ const ordered: typeof state.projects = []
146
+ let idx = 0
147
+ for (const id of action.orderedIds) {
148
+ const s = byId.get(id)
149
+ if (s) {
150
+ ordered.push({ ...s, order: idx })
151
+ byId.delete(id)
152
+ }
153
+ idx++
154
+ }
155
+ let nextOrder = ordered.length
156
+ for (const s of byId.values()) {
157
+ ordered.push({ ...s, order: nextOrder++ })
158
+ }
159
+ return { ...state, projects: ordered }
160
+ }
161
+ case 'reorder-active-project': {
162
+ const currentId = state.currentProjectId
163
+ if (currentId == null || currentId === '') return state
164
+ const ids = orderProjectsForDisplay(state.projects).map((s) => s.id)
165
+ const from = ids.indexOf(currentId)
166
+ if (from < 0) return state
167
+ const to = from + action.delta
168
+ const targetId = ids[to]
169
+ if (targetId == null) return state
170
+ const nextIds = moveIdToIdPosition(ids, currentId, targetId)
171
+ const byId = new Map(state.projects.map((s) => [s.id, s]))
172
+ const nextProjects = nextIds.map((id, idx) => {
173
+ const s = byId.get(id)
174
+ return s ? { ...s, order: idx } : s
175
+ })
176
+ const filtered = nextProjects.filter((s): s is NonNullable<typeof s> => s != null)
177
+ if (filtered.length !== state.projects.length) return state
178
+ return { ...state, projects: filtered }
179
+ }
180
+ case 'set-project-status': {
181
+ const prev = state.projectStatuses[action.projectId]
182
+ if (
183
+ prev !== undefined &&
184
+ prev.working === action.status.working &&
185
+ prev.waiting === action.status.waiting
186
+ ) {
187
+ return state
188
+ }
189
+ return {
190
+ ...state,
191
+ projectStatuses: { ...state.projectStatuses, [action.projectId]: action.status },
192
+ }
193
+ }
194
+ case 'set-workspace-activity': {
195
+ const prev = state.workspaceActivity[action.workspaceId] ?? IDLE_WORKSPACE_ACTIVITY
196
+ // Going back to work is the other way a "finished, unseen" tick clears:
197
+ // the workspace has something newer to say than "it ended a turn once".
198
+ const done = action.working || action.waiting ? false : prev.done
199
+ if (
200
+ prev.working === action.working &&
201
+ prev.waiting === action.waiting &&
202
+ prev.done === done
203
+ ) {
204
+ return state
205
+ }
206
+ return {
207
+ ...state,
208
+ workspaceActivity: {
209
+ ...state.workspaceActivity,
210
+ [action.workspaceId]: { done, waiting: action.waiting, working: action.working },
211
+ },
212
+ }
213
+ }
214
+ case 'mark-workspace-done': {
215
+ const prev = state.workspaceActivity[action.workspaceId] ?? IDLE_WORKSPACE_ACTIVITY
216
+ if (prev.done) return state
217
+ return {
218
+ ...state,
219
+ workspaceActivity: {
220
+ ...state.workspaceActivity,
221
+ [action.workspaceId]: { ...prev, done: true },
222
+ },
223
+ }
224
+ }
225
+ case 'add-workspace-record':
226
+ return {
227
+ ...state,
228
+ projects: state.projects.map((project) => {
229
+ if (project.id !== action.projectId) return project
230
+ const next = {
231
+ ...project,
232
+ activeWorkspaceId:
233
+ action.activate === true ? action.workspace.id : project.activeWorkspaceId,
234
+ updatedAt: new Date().toISOString(),
235
+ workspaces: [...(project.workspaces ?? []), action.workspace],
236
+ }
237
+ return next.activeWorkspaceId != null && next.activeWorkspaceId !== ''
238
+ ? next
239
+ : withActiveWorkspace(next, action.workspace.id)
240
+ }),
241
+ }
242
+ case 'set-active-workspace': {
243
+ const projects = state.projects.map((project) =>
244
+ project.id === action.projectId ? withActiveWorkspace(project, action.workspaceId) : project
245
+ )
246
+ // Remember the tab we're leaving so coming back to this workspace later
247
+ // restores it instead of snapping to the first tab. Keyed by the
248
+ // workspace we're departing (the current project's active one).
249
+ const workspaceActivity = clearWorkspaceDone(state.workspaceActivity, action.workspaceId)
250
+ const leavingProject = state.projects.find((s) => s.id === action.projectId)
251
+ const leavingWorkspaceId = leavingProject?.activeWorkspaceId
252
+ const lastActiveTabByWorkspace =
253
+ action.projectId === state.currentProjectId &&
254
+ leavingWorkspaceId != null &&
255
+ leavingWorkspaceId !== '' &&
256
+ state.activeTabId != null &&
257
+ state.activeTabId !== ''
258
+ ? { ...state.lastActiveTabByWorkspace, [leavingWorkspaceId]: state.activeTabId }
259
+ : state.lastActiveTabByWorkspace
260
+ // Changing the workspace of a non-current project has no effect on
261
+ // which tab the user is looking at — leave activeTabId alone.
262
+ if (action.projectId !== state.currentProjectId) {
263
+ return { ...state, lastActiveTabByWorkspace, projects, workspaceActivity }
264
+ }
265
+ // For the current project: if the existing activeTabId belongs to
266
+ // the new workspace, keep it. Otherwise restore the last tab we viewed
267
+ // in that workspace, falling back to the first visible tab (or clearing
268
+ // it if the workspace is empty — the pane then renders the "this
269
+ // workspace has no tabs" placeholder).
270
+ const updated = projects.find((s) => s.id === action.projectId)
271
+ const visible = filterTabsForActiveWorkspace(state.tabs, updated)
272
+ const currentStillVisible =
273
+ state.activeTabId != null &&
274
+ state.activeTabId !== '' &&
275
+ visible.some((t) => t.id === state.activeTabId)
276
+ if (currentStillVisible) {
277
+ return { ...state, lastActiveTabByWorkspace, projects, workspaceActivity }
278
+ }
279
+ const remembered = lastActiveTabByWorkspace[action.workspaceId]
280
+ const nextActiveTabId =
281
+ remembered != null && remembered !== '' && visible.some((t) => t.id === remembered)
282
+ ? remembered
283
+ : (visible[0]?.id ?? null)
284
+ return {
285
+ ...state,
286
+ activeTabId: nextActiveTabId,
287
+ lastActiveTabByWorkspace,
288
+ projects,
289
+ workspaceActivity,
290
+ }
291
+ }
292
+ case 'update-workspace-record':
293
+ return {
294
+ ...state,
295
+ projects: state.projects.map((project) =>
296
+ project.id === action.projectId
297
+ ? {
298
+ ...project,
299
+ updatedAt: new Date().toISOString(),
300
+ workspaces: project.workspaces?.map((workspace) =>
301
+ workspace.id === action.workspaceId
302
+ ? { ...workspace, ...action.patch, updatedAt: new Date().toISOString() }
303
+ : workspace
304
+ ),
305
+ }
306
+ : project
307
+ ),
308
+ }
309
+ default:
310
+ return null
311
+ }
312
+ }
@@ -0,0 +1,80 @@
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState, SettingsUIState } from '../types'
3
+
4
+ import { DEFAULT_SECTION_ID, getSectionRowCount, SETTING_SECTIONS } from '../../settings/sections'
5
+
6
+ export function emptySettingsUI(): SettingsUIState {
7
+ return { pane: 'nav', rowIndex: 0, sectionId: DEFAULT_SECTION_ID }
8
+ }
9
+
10
+ function clamp(value: number, max: number): number {
11
+ if (max < 0) return 0
12
+ return Math.max(0, Math.min(max, value))
13
+ }
14
+
15
+ function withSettings(state: AppState, patch: Partial<SettingsUIState>): AppState {
16
+ return { ...state, settings: { ...state.settings, ...patch } }
17
+ }
18
+
19
+ function moveSelection(state: AppState, delta: -1 | 1): AppState {
20
+ const { pane, rowIndex, sectionId } = state.settings
21
+
22
+ if (pane === 'nav') {
23
+ const index = SETTING_SECTIONS.findIndex((section) => section.id === sectionId)
24
+ const next = clamp((index === -1 ? 0 : index) + delta, SETTING_SECTIONS.length - 1)
25
+ const nextId = SETTING_SECTIONS[next]?.id
26
+ if (nextId == null || nextId === sectionId) return state
27
+ // A new section has its own rows, so the row cursor from the old one means
28
+ // nothing here.
29
+ return withSettings(state, { rowIndex: 0, sectionId: nextId })
30
+ }
31
+
32
+ return withSettings(state, {
33
+ rowIndex: clamp(rowIndex + delta, getSectionRowCount(sectionId, state.projects) - 1),
34
+ })
35
+ }
36
+
37
+ export function reduceSettingsState(state: AppState, action: AppAction): AppState | null {
38
+ switch (action.type) {
39
+ case 'enter-settings':
40
+ if (state.focusMode === 'settings') return state
41
+ // The section is remembered across a close/open within the session; the
42
+ // cursor inside it is not — reopening lands you at the top of it.
43
+ return {
44
+ ...state,
45
+ focusMode: 'settings',
46
+ settings: { ...state.settings, pane: 'nav', rowIndex: 0 },
47
+ }
48
+ case 'exit-settings':
49
+ if (state.focusMode !== 'settings') return state
50
+ return { ...state, focusMode: 'navigation' }
51
+ case 'settings-focus-pane': {
52
+ // A section with no rows has nothing to focus, so `l` stays put rather
53
+ // than parking the cursor in an empty column.
54
+ if (
55
+ action.pane === 'rows' &&
56
+ getSectionRowCount(state.settings.sectionId, state.projects) === 0
57
+ ) {
58
+ return state
59
+ }
60
+ return withSettings(state, { pane: action.pane })
61
+ }
62
+ case 'settings-move-selection':
63
+ return moveSelection(state, action.delta)
64
+ case 'settings-select-section':
65
+ if (action.sectionId === state.settings.sectionId) {
66
+ return withSettings(state, { pane: 'nav' })
67
+ }
68
+ return withSettings(state, { pane: 'nav', rowIndex: 0, sectionId: action.sectionId })
69
+ case 'settings-select-row':
70
+ return withSettings(state, {
71
+ pane: 'rows',
72
+ rowIndex: clamp(
73
+ action.rowIndex,
74
+ getSectionRowCount(state.settings.sectionId, state.projects) - 1
75
+ ),
76
+ })
77
+ default:
78
+ return null
79
+ }
80
+ }