| ... | ... | @@ -1,8 +1,5 @@ |
| 1 | 1 | /** |
| 2 | | * Shared thread terminal state and reducer. Used by both the main app store |
| 3 | | * (persisted threads) and the composer draft store (draft threads), so we can |
| 4 | | * reuse the same shape and helpers and easily copy terminal state from draft |
| 5 | | * to thread when reconciling server state. |
| 2 | * Shared thread terminal state utilities used by the terminal Zustand store. |
| 6 | 3 | */ |
| 7 | 4 | |
| 8 | 5 | import { |
| ... | ... | @@ -23,16 +20,7 @@ export interface ThreadTerminalState { |
| 23 | 20 | activeTerminalGroupId: string; |
| 24 | 21 | } |
| 25 | 22 | |
| 26 | | export type ThreadTerminalAction = |
| 27 | | | { type: "set-open"; open: boolean } |
| 28 | | | { type: "set-height"; height: number } |
| 29 | | | { type: "split"; terminalId: string } |
| 30 | | | { type: "new"; terminalId: string } |
| 31 | | | { type: "set-active"; terminalId: string } |
| 32 | | | { type: "close"; terminalId: string } |
| 33 | | | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean }; |
| 34 | | |
| 35 | | // ─── Helpers (exported for store reducers that need to work with Thread) ─── |
| 23 | // ─── Helpers shared by terminal updaters ─────────────────────────────────── |
| 36 | 24 | |
| 37 | 25 | export function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 38 | 26 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( |
| ... | ... | @@ -129,6 +117,38 @@ export function normalizeTerminalGroups( |
| 129 | 117 | return nextGroups; |
| 130 | 118 | } |
| 131 | 119 | |
| 120 | function arraysEqual(a: string[], b: string[]): boolean { |
| 121 | if (a.length !== b.length) return false; |
| 122 | for (let index = 0; index < a.length; index += 1) { |
| 123 | if (a[index] !== b[index]) return false; |
| 124 | } |
| 125 | return true; |
| 126 | } |
| 127 | |
| 128 | function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { |
| 129 | if (left.length !== right.length) return false; |
| 130 | for (let index = 0; index < left.length; index += 1) { |
| 131 | const leftGroup = left[index]; |
| 132 | const rightGroup = right[index]; |
| 133 | if (!leftGroup || !rightGroup) return false; |
| 134 | if (leftGroup.id !== rightGroup.id) return false; |
| 135 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; |
| 136 | } |
| 137 | return true; |
| 138 | } |
| 139 | |
| 140 | function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean { |
| 141 | return ( |
| 142 | left.terminalOpen === right.terminalOpen && |
| 143 | left.terminalHeight === right.terminalHeight && |
| 144 | left.activeTerminalId === right.activeTerminalId && |
| 145 | left.activeTerminalGroupId === right.activeTerminalGroupId && |
| 146 | arraysEqual(left.terminalIds, right.terminalIds) && |
| 147 | arraysEqual(left.runningTerminalIds, right.runningTerminalIds) && |
| 148 | terminalGroupsEqual(left.terminalGroups, right.terminalGroups) |
| 149 | ); |
| 150 | } |
| 151 | |
| 132 | 152 | // ─── Default & normalize ───────────────────────────────────────────────── |
| 133 | 153 | |
| 134 | 154 | const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ |
| ... | ... | @@ -171,7 +191,7 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread |
| 171 | 191 | const activeGroupIdFromTerminal = |
| 172 | 192 | terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; |
| 173 | 193 | |
| 174 | | return { |
| 194 | const normalized: ThreadTerminalState = { |
| 175 | 195 | terminalOpen: state.terminalOpen, |
| 176 | 196 | terminalHeight: |
| 177 | 197 | Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 |
| ... | ... | @@ -187,52 +207,16 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread |
| 187 | 207 | terminalGroups[0]?.id ?? |
| 188 | 208 | fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), |
| 189 | 209 | }; |
| 210 | return threadTerminalStateEqual(state, normalized) ? state : normalized; |
| 190 | 211 | } |
| 191 | 212 | |
| 192 | | // ─── Close terminal (shared) ───────────────────────────────────────────── |
| 193 | | |
| 194 | | function closeTerminal( |
| 195 | | state: ThreadTerminalState, |
| 196 | | terminalId: string, |
| 197 | | ): ThreadTerminalState { |
| 198 | | if (!state.terminalIds.includes(terminalId)) { |
| 199 | | return state; |
| 200 | | } |
| 201 | | |
| 202 | | const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId); |
| 203 | | if (remainingTerminalIds.length === 0) { |
| 204 | | return createDefaultThreadTerminalState(); |
| 205 | | } |
| 206 | | |
| 207 | | const closedTerminalIndex = state.terminalIds.indexOf(terminalId); |
| 208 | | const nextActiveTerminalId = |
| 209 | | state.activeTerminalId === terminalId |
| 210 | | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? |
| 211 | | remainingTerminalIds[0] ?? |
| 212 | | DEFAULT_THREAD_TERMINAL_ID) |
| 213 | | : state.activeTerminalId; |
| 214 | | |
| 215 | | const terminalGroups = state.terminalGroups |
| 216 | | .map((group) => ({ |
| 217 | | ...group, |
| 218 | | terminalIds: group.terminalIds.filter((id) => id !== terminalId), |
| 219 | | })) |
| 220 | | .filter((group) => group.terminalIds.length > 0); |
| 221 | | |
| 222 | | const nextActiveTerminalGroupId = |
| 223 | | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? |
| 224 | | terminalGroups[0]?.id ?? |
| 225 | | fallbackGroupId(nextActiveTerminalId); |
| 213 | export function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { |
| 214 | const normalized = normalizeThreadTerminalState(state); |
| 215 | return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); |
| 216 | } |
| 226 | 217 | |
| 227 | | return normalizeThreadTerminalState({ |
| 228 | | terminalOpen: state.terminalOpen, |
| 229 | | terminalHeight: state.terminalHeight, |
| 230 | | terminalIds: remainingTerminalIds, |
| 231 | | runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId), |
| 232 | | activeTerminalId: nextActiveTerminalId, |
| 233 | | terminalGroups, |
| 234 | | activeTerminalGroupId: nextActiveTerminalGroupId, |
| 235 | | }); |
| 218 | function isValidTerminalId(terminalId: string): boolean { |
| 219 | return terminalId.trim().length > 0; |
| 236 | 220 | } |
| 237 | 221 | |
| 238 | 222 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { |
| ... | ... | @@ -242,109 +226,55 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ |
| 242 | 226 | })); |
| 243 | 227 | } |
| 244 | 228 | |
| 245 | | // ─── Single reducer for both draft and persisted thread ──────────────────── |
| 246 | | |
| 247 | | export function reduceThreadTerminalState( |
| 229 | function upsertTerminalIntoGroups( |
| 248 | 230 | state: ThreadTerminalState, |
| 249 | | action: ThreadTerminalAction, |
| 231 | terminalId: string, |
| 232 | mode: "split" | "new", |
| 250 | 233 | ): ThreadTerminalState { |
| 251 | 234 | const normalized = normalizeThreadTerminalState(state); |
| 252 | | |
| 253 | | if (action.type === "set-open") { |
| 254 | | if (normalized.terminalOpen === action.open) return normalized; |
| 255 | | return { ...normalized, terminalOpen: action.open }; |
| 256 | | } |
| 257 | | |
| 258 | | if (action.type === "set-height") { |
| 259 | | if ( |
| 260 | | !Number.isFinite(action.height) || |
| 261 | | action.height <= 0 || |
| 262 | | normalized.terminalHeight === action.height |
| 263 | | ) { |
| 264 | | return normalized; |
| 265 | | } |
| 266 | | return { ...normalized, terminalHeight: action.height }; |
| 267 | | } |
| 268 | | |
| 269 | | if (action.type === "set-active") { |
| 270 | | if (!normalized.terminalIds.includes(action.terminalId)) { |
| 271 | | return normalized; |
| 272 | | } |
| 273 | | const activeTerminalGroupId = |
| 274 | | normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ?? |
| 275 | | normalized.activeTerminalGroupId; |
| 276 | | return { |
| 277 | | ...normalized, |
| 278 | | activeTerminalId: action.terminalId, |
| 279 | | activeTerminalGroupId, |
| 280 | | }; |
| 281 | | } |
| 282 | | |
| 283 | | if (action.type === "close") { |
| 284 | | return closeTerminal(normalized, action.terminalId); |
| 285 | | } |
| 286 | | |
| 287 | | if (action.type === "set-activity") { |
| 288 | | if (!normalized.terminalIds.includes(action.terminalId)) { |
| 289 | | return normalized; |
| 290 | | } |
| 291 | | const runningTerminalIds = new Set(normalized.runningTerminalIds); |
| 292 | | if (action.hasRunningSubprocess) { |
| 293 | | runningTerminalIds.add(action.terminalId); |
| 294 | | } else { |
| 295 | | runningTerminalIds.delete(action.terminalId); |
| 296 | | } |
| 297 | | return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; |
| 298 | | } |
| 299 | | |
| 300 | | if (!action.terminalId || action.terminalId.trim().length === 0) { |
| 235 | if (!isValidTerminalId(terminalId)) { |
| 301 | 236 | return normalized; |
| 302 | 237 | } |
| 303 | 238 | |
| 304 | | const isNewTerminal = !normalized.terminalIds.includes(action.terminalId); |
| 239 | const isNewTerminal = !normalized.terminalIds.includes(terminalId); |
| 305 | 240 | if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { |
| 306 | 241 | return normalized; |
| 307 | 242 | } |
| 308 | 243 | |
| 309 | | const terminalIds = isNewTerminal |
| 310 | | ? [...normalized.terminalIds, action.terminalId] |
| 311 | | : normalized.terminalIds; |
| 244 | const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; |
| 312 | 245 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); |
| 313 | | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId); |
| 246 | |
| 247 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); |
| 314 | 248 | if (existingGroupIndex >= 0) { |
| 315 | | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ |
| 316 | | existingGroupIndex |
| 317 | | ]!.terminalIds.filter((id) => id !== action.terminalId); |
| 249 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( |
| 250 | (id) => id !== terminalId, |
| 251 | ); |
| 318 | 252 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { |
| 319 | 253 | terminalGroups.splice(existingGroupIndex, 1); |
| 320 | 254 | } |
| 321 | 255 | } |
| 322 | 256 | |
| 323 | | if (action.type === "new") { |
| 257 | if (mode === "new") { |
| 324 | 258 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); |
| 325 | | const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds); |
| 326 | | terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); |
| 259 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); |
| 260 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); |
| 327 | 261 | return normalizeThreadTerminalState({ |
| 328 | 262 | ...normalized, |
| 329 | 263 | terminalOpen: true, |
| 330 | 264 | terminalIds, |
| 331 | | activeTerminalId: action.terminalId, |
| 265 | activeTerminalId: terminalId, |
| 332 | 266 | terminalGroups, |
| 333 | 267 | activeTerminalGroupId: nextGroupId, |
| 334 | 268 | }); |
| 335 | 269 | } |
| 336 | 270 | |
| 337 | | // split |
| 338 | 271 | let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); |
| 339 | 272 | if (activeGroupIndex < 0) { |
| 340 | 273 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); |
| 341 | 274 | } |
| 342 | 275 | if (activeGroupIndex < 0) { |
| 343 | 276 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); |
| 344 | | const nextGroupId = assignUniqueGroupId( |
| 345 | | fallbackGroupId(normalized.activeTerminalId), |
| 346 | | usedGroupIds, |
| 347 | | ); |
| 277 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds); |
| 348 | 278 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); |
| 349 | 279 | activeGroupIndex = terminalGroups.length - 1; |
| 350 | 280 | } |
| ... | ... | @@ -354,12 +284,12 @@ export function reduceThreadTerminalState( |
| 354 | 284 | return normalized; |
| 355 | 285 | } |
| 356 | 286 | |
| 357 | | if (!destinationGroup.terminalIds.includes(action.terminalId)) { |
| 287 | if (!destinationGroup.terminalIds.includes(terminalId)) { |
| 358 | 288 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); |
| 359 | 289 | if (anchorIndex >= 0) { |
| 360 | | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); |
| 290 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); |
| 361 | 291 | } else { |
| 362 | | destinationGroup.terminalIds.push(action.terminalId); |
| 292 | destinationGroup.terminalIds.push(terminalId); |
| 363 | 293 | } |
| 364 | 294 | } |
| 365 | 295 | |
| ... | ... | @@ -367,8 +297,132 @@ export function reduceThreadTerminalState( |
| 367 | 297 | ...normalized, |
| 368 | 298 | terminalOpen: true, |
| 369 | 299 | terminalIds, |
| 370 | | activeTerminalId: action.terminalId, |
| 300 | activeTerminalId: terminalId, |
| 371 | 301 | terminalGroups, |
| 372 | 302 | activeTerminalGroupId: destinationGroup.id, |
| 373 | 303 | }); |
| 374 | 304 | } |
| 305 | |
| 306 | // ─── Pure terminal operations ────────────────────────────────────────────── |
| 307 | |
| 308 | export function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { |
| 309 | const normalized = normalizeThreadTerminalState(state); |
| 310 | if (normalized.terminalOpen === open) return normalized; |
| 311 | return { ...normalized, terminalOpen: open }; |
| 312 | } |
| 313 | |
| 314 | export function setThreadTerminalHeight( |
| 315 | state: ThreadTerminalState, |
| 316 | height: number, |
| 317 | ): ThreadTerminalState { |
| 318 | const normalized = normalizeThreadTerminalState(state); |
| 319 | if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { |
| 320 | return normalized; |
| 321 | } |
| 322 | return { ...normalized, terminalHeight: height }; |
| 323 | } |
| 324 | |
| 325 | export function splitThreadTerminal( |
| 326 | state: ThreadTerminalState, |
| 327 | terminalId: string, |
| 328 | ): ThreadTerminalState { |
| 329 | return upsertTerminalIntoGroups(state, terminalId, "split"); |
| 330 | } |
| 331 | |
| 332 | export function newThreadTerminal( |
| 333 | state: ThreadTerminalState, |
| 334 | terminalId: string, |
| 335 | ): ThreadTerminalState { |
| 336 | return upsertTerminalIntoGroups(state, terminalId, "new"); |
| 337 | } |
| 338 | |
| 339 | export function setThreadActiveTerminal( |
| 340 | state: ThreadTerminalState, |
| 341 | terminalId: string, |
| 342 | ): ThreadTerminalState { |
| 343 | const normalized = normalizeThreadTerminalState(state); |
| 344 | if (!normalized.terminalIds.includes(terminalId)) { |
| 345 | return normalized; |
| 346 | } |
| 347 | const activeTerminalGroupId = |
| 348 | normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? |
| 349 | normalized.activeTerminalGroupId; |
| 350 | if ( |
| 351 | normalized.activeTerminalId === terminalId && |
| 352 | normalized.activeTerminalGroupId === activeTerminalGroupId |
| 353 | ) { |
| 354 | return normalized; |
| 355 | } |
| 356 | return { |
| 357 | ...normalized, |
| 358 | activeTerminalId: terminalId, |
| 359 | activeTerminalGroupId, |
| 360 | }; |
| 361 | } |
| 362 | |
| 363 | export function closeThreadTerminal( |
| 364 | state: ThreadTerminalState, |
| 365 | terminalId: string, |
| 366 | ): ThreadTerminalState { |
| 367 | const normalized = normalizeThreadTerminalState(state); |
| 368 | if (!normalized.terminalIds.includes(terminalId)) { |
| 369 | return normalized; |
| 370 | } |
| 371 | |
| 372 | const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); |
| 373 | if (remainingTerminalIds.length === 0) { |
| 374 | return createDefaultThreadTerminalState(); |
| 375 | } |
| 376 | |
| 377 | const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); |
| 378 | const nextActiveTerminalId = |
| 379 | normalized.activeTerminalId === terminalId |
| 380 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? |
| 381 | remainingTerminalIds[0] ?? |
| 382 | DEFAULT_THREAD_TERMINAL_ID) |
| 383 | : normalized.activeTerminalId; |
| 384 | |
| 385 | const terminalGroups = normalized.terminalGroups |
| 386 | .map((group) => ({ |
| 387 | ...group, |
| 388 | terminalIds: group.terminalIds.filter((id) => id !== terminalId), |
| 389 | })) |
| 390 | .filter((group) => group.terminalIds.length > 0); |
| 391 | |
| 392 | const nextActiveTerminalGroupId = |
| 393 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? |
| 394 | terminalGroups[0]?.id ?? |
| 395 | fallbackGroupId(nextActiveTerminalId); |
| 396 | |
| 397 | return normalizeThreadTerminalState({ |
| 398 | terminalOpen: normalized.terminalOpen, |
| 399 | terminalHeight: normalized.terminalHeight, |
| 400 | terminalIds: remainingTerminalIds, |
| 401 | runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId), |
| 402 | activeTerminalId: nextActiveTerminalId, |
| 403 | terminalGroups, |
| 404 | activeTerminalGroupId: nextActiveTerminalGroupId, |
| 405 | }); |
| 406 | } |
| 407 | |
| 408 | export function setThreadTerminalActivity( |
| 409 | state: ThreadTerminalState, |
| 410 | terminalId: string, |
| 411 | hasRunningSubprocess: boolean, |
| 412 | ): ThreadTerminalState { |
| 413 | const normalized = normalizeThreadTerminalState(state); |
| 414 | if (!normalized.terminalIds.includes(terminalId)) { |
| 415 | return normalized; |
| 416 | } |
| 417 | const alreadyRunning = normalized.runningTerminalIds.includes(terminalId); |
| 418 | if (hasRunningSubprocess === alreadyRunning) { |
| 419 | return normalized; |
| 420 | } |
| 421 | const runningTerminalIds = new Set(normalized.runningTerminalIds); |
| 422 | if (hasRunningSubprocess) { |
| 423 | runningTerminalIds.add(terminalId); |
| 424 | } else { |
| 425 | runningTerminalIds.delete(terminalId); |
| 426 | } |
| 427 | return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; |
| 428 | } |