loop-task 2.0.1 → 2.0.3

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 (126) hide show
  1. package/dist/i18n/en.json +6 -6
  2. package/dist/tui/{app.js → App.js} +1 -1
  3. package/dist/tui/components/CommandInput.js +16 -38
  4. package/dist/tui/components/CreateForm.js +2 -2
  5. package/dist/tui/components/WizardForm.js +18 -4
  6. package/dist/tui/index.js +5 -3
  7. package/package.json +2 -2
  8. package/dist/board/App.js +0 -405
  9. package/dist/board/board-log.js +0 -18
  10. package/dist/board/components/ActionButtons.js +0 -46
  11. package/dist/board/components/BoardButton.js +0 -58
  12. package/dist/board/components/ConfirmModal.js +0 -36
  13. package/dist/board/components/ContextHelpModal.js +0 -22
  14. package/dist/board/components/CreateForm.js +0 -271
  15. package/dist/board/components/CreateProjectModal.js +0 -97
  16. package/dist/board/components/DeleteProjectConfirm.js +0 -53
  17. package/dist/board/components/DetailView.js +0 -25
  18. package/dist/board/components/EditProjectModal.js +0 -99
  19. package/dist/board/components/FilterBar.js +0 -17
  20. package/dist/board/components/Footer.js +0 -52
  21. package/dist/board/components/Header.js +0 -46
  22. package/dist/board/components/HelpModal.js +0 -65
  23. package/dist/board/components/Inspector.js +0 -12
  24. package/dist/board/components/LogModal.js +0 -82
  25. package/dist/board/components/Navigator.js +0 -84
  26. package/dist/board/components/ProjectsModal.js +0 -70
  27. package/dist/board/components/ProjectsPage.js +0 -134
  28. package/dist/board/components/RunHistory.js +0 -83
  29. package/dist/board/components/SearchBox.js +0 -10
  30. package/dist/board/components/SearchSelect.js +0 -132
  31. package/dist/board/components/TaskBrowser.js +0 -95
  32. package/dist/board/components/TaskFilterBar.js +0 -6
  33. package/dist/board/components/TaskForm.js +0 -155
  34. package/dist/board/components/Timeline.js +0 -5
  35. package/dist/board/daemon.js +0 -117
  36. package/dist/board/focus-context.js +0 -98
  37. package/dist/board/hooks/useBoardKeybindings.js +0 -344
  38. package/dist/board/hooks/useBreakpoint.js +0 -6
  39. package/dist/board/hooks/useHoverState.js +0 -11
  40. package/dist/board/hooks/useInputShortcuts.js +0 -61
  41. package/dist/board/hooks/useLogStream.js +0 -32
  42. package/dist/board/hooks/useLoopPolling.js +0 -28
  43. package/dist/board/hooks/useTabNav.js +0 -60
  44. package/dist/board/hooks/useTaskKeybindings.js +0 -120
  45. package/dist/board/index.js +0 -32
  46. package/dist/board/router.js +0 -16
  47. package/dist/board/state.js +0 -99
  48. package/dist/board/toast.js +0 -65
  49. package/dist/board/types.js +0 -1
  50. package/dist/cli.d.ts +0 -3
  51. package/dist/cli.d.ts.map +0 -1
  52. package/dist/cli.js.map +0 -1
  53. package/dist/client/commands.d.ts +0 -11
  54. package/dist/client/commands.d.ts.map +0 -1
  55. package/dist/client/commands.js.map +0 -1
  56. package/dist/client/ipc.d.ts +0 -5
  57. package/dist/client/ipc.d.ts.map +0 -1
  58. package/dist/client/ipc.js.map +0 -1
  59. package/dist/daemon/index.d.ts +0 -2
  60. package/dist/daemon/index.d.ts.map +0 -1
  61. package/dist/daemon/index.js.map +0 -1
  62. package/dist/daemon/manager.d.ts +0 -17
  63. package/dist/daemon/manager.d.ts.map +0 -1
  64. package/dist/daemon/manager.js.map +0 -1
  65. package/dist/daemon/server.d.ts +0 -15
  66. package/dist/daemon/server.d.ts.map +0 -1
  67. package/dist/daemon/server.js.map +0 -1
  68. package/dist/daemon/spawner.d.ts +0 -3
  69. package/dist/daemon/spawner.d.ts.map +0 -1
  70. package/dist/daemon/spawner.js.map +0 -1
  71. package/dist/daemon/state.d.ts +0 -15
  72. package/dist/daemon/state.d.ts.map +0 -1
  73. package/dist/daemon/state.js.map +0 -1
  74. package/dist/duration.d.ts +0 -3
  75. package/dist/duration.d.ts.map +0 -1
  76. package/dist/duration.js.map +0 -1
  77. package/dist/logger.d.ts +0 -11
  78. package/dist/logger.d.ts.map +0 -1
  79. package/dist/logger.js.map +0 -1
  80. package/dist/loop-config.d.ts +0 -13
  81. package/dist/loop-config.d.ts.map +0 -1
  82. package/dist/loop-config.js.map +0 -1
  83. package/dist/loop.d.ts +0 -46
  84. package/dist/loop.d.ts.map +0 -1
  85. package/dist/loop.js +0 -363
  86. package/dist/loop.js.map +0 -1
  87. package/dist/open-board.d.ts +0 -2
  88. package/dist/open-board.d.ts.map +0 -1
  89. package/dist/open-board.js +0 -19
  90. package/dist/open-board.js.map +0 -1
  91. package/dist/tui/app.d.ts +0 -57
  92. package/dist/tui/app.d.ts.map +0 -1
  93. package/dist/tui/app.js.map +0 -1
  94. package/dist/tui/components/ActionButtons.js +0 -49
  95. package/dist/tui/components/CommandBuilderField.js +0 -143
  96. package/dist/tui/components/CommandEditorModal.js +0 -159
  97. package/dist/tui/components/ConfirmModal.js +0 -9
  98. package/dist/tui/components/FilterBar.js +0 -13
  99. package/dist/tui/components/FocusableSearchSelect.js +0 -82
  100. package/dist/tui/components/Footer.js +0 -42
  101. package/dist/tui/components/PatchEditForm.js +0 -78
  102. package/dist/tui/components/SearchSelect.js +0 -67
  103. package/dist/tui/components/TaskFilterBar.js +0 -9
  104. package/dist/tui/create-loop.d.ts +0 -3
  105. package/dist/tui/create-loop.d.ts.map +0 -1
  106. package/dist/tui/create-loop.js +0 -90
  107. package/dist/tui/create-loop.js.map +0 -1
  108. package/dist/tui/dashboard.d.ts +0 -2
  109. package/dist/tui/dashboard.d.ts.map +0 -1
  110. package/dist/tui/dashboard.js +0 -45
  111. package/dist/tui/dashboard.js.map +0 -1
  112. package/dist/tui/events.d.ts +0 -3
  113. package/dist/tui/events.d.ts.map +0 -1
  114. package/dist/tui/events.js +0 -244
  115. package/dist/tui/events.js.map +0 -1
  116. package/dist/tui/popup.d.ts +0 -3
  117. package/dist/tui/popup.d.ts.map +0 -1
  118. package/dist/tui/popup.js +0 -58
  119. package/dist/tui/popup.js.map +0 -1
  120. package/dist/tui/render.d.ts +0 -9
  121. package/dist/tui/render.d.ts.map +0 -1
  122. package/dist/tui/render.js +0 -305
  123. package/dist/tui/render.js.map +0 -1
  124. package/dist/types.d.ts +0 -94
  125. package/dist/types.d.ts.map +0 -1
  126. package/dist/types.js.map +0 -1
package/dist/i18n/en.json CHANGED
@@ -539,11 +539,11 @@
539
539
  "cmdInput.searchPlaceholder": "Write something to filter about...",
540
540
  "cmdInput.confirmYes": "yes",
541
541
  "cmdInput.confirmCancel": "cancel",
542
- "confirm.deleteLoop": "Delete \"{name}\"? type yes to confirm",
543
- "confirm.deleteTask": "Delete task \"{id}\"? type yes to confirm",
544
- "confirm.deleteProject": "Delete project \"{name}\"? type yes to confirm",
545
- "confirm.stopLoop": "Stop \"{name}\"? type yes to confirm",
546
- "confirm.quit": "Exit loop-task? Running loops keep running in background.",
542
+ "confirm.deleteLoop": "Type yes to delete \"{name}\"",
543
+ "confirm.deleteTask": "Type yes to delete task \"{id}\"",
544
+ "confirm.deleteProject": "Type yes to delete project \"{name}\"",
545
+ "confirm.stopLoop": "Type yes to stop \"{name}\"",
546
+ "confirm.quit": "Type yes to exit loop-task",
547
547
  "wizard.newLoop": "New loop",
548
548
  "wizard.editLoop": "Edit loop",
549
549
  "wizard.newTask": "New task",
@@ -560,7 +560,7 @@
560
560
  "wizard.runNowNow": "Run now, then every interval",
561
561
  "wizard.cwdPrompt": "Working directory? (optional)",
562
562
  "wizard.cwdHint": "Directory the command runs in",
563
- "wizard.descriptionPrompt": "Description? (optional)",
563
+ "wizard.descriptionPrompt": "Description?",
564
564
  "wizard.descriptionHint": "Short label shown in the list",
565
565
  "wizard.maxRunsPrompt": "Max runs? (optional)",
566
566
  "wizard.maxRunsHint": "Stop after N runs. Leave blank to run forever",
@@ -233,7 +233,7 @@ export function App(props) {
233
233
  onConfirm: () => { void deleteTask(selectedTask.id).then(() => { void refreshTasks(); }); },
234
234
  });
235
235
  }
236
- else if (activeTab === "projects" && selectedProjectEntity) {
236
+ else if (activeTab === "projects" && selectedProjectEntity && !selectedProjectEntity.isSystem) {
237
237
  setConfirmState({
238
238
  prompt: t("confirm.deleteProject", { name: selectedProjectEntity.name }),
239
239
  onConfirm: async () => {
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useCallback } from "react";
2
+ import { useState, useEffect, useMemo, useCallback } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { useAutocompleteState, } from "ink-combobox";
5
5
  import { darkTheme as theme } from "../theme.js";
6
6
  import { t } from "../../i18n/index.js";
7
7
  import { buildCommands, rankCommands } from "../commands.js";
8
- import { COMMAND_INPUT_DROPDOWN_MAX_VISIBLE, COMMAND_INPUT_HEIGHT, CONFIRM_YES, CONFIRM_CANCEL, } from "../../config/constants.js";
8
+ import { COMMAND_INPUT_DROPDOWN_MAX_VISIBLE, COMMAND_INPUT_HEIGHT, } from "../../config/constants.js";
9
9
  import { sanitizePaste } from "../utils/paste.js";
10
10
  export { sanitizePaste } from "../utils/paste.js";
11
11
  // ── Rendered input with cursor ───────────────────────────────────────
@@ -76,13 +76,6 @@ function CommandDropdown({ state, rankedFiltered, }) {
76
76
  }), belowCount > 0 && (_jsx(Text, { color: theme.text.muted, children: ` \u2193 ${belowCount} more` }))] }));
77
77
  }
78
78
  // ── Confirm inline options ────────────────────────────────────────────
79
- function ConfirmInlineOptions({ focusedIndex, yesLabel, cancelLabel, }) {
80
- // Options order: [cancel(0), yes(1)] in the autocomplete state,
81
- // displayed inline as "❯ yes cancel" (yes first, cancel second).
82
- const yesFocused = focusedIndex === 1;
83
- const cancelFocused = focusedIndex === 0;
84
- return (_jsxs(Box, { paddingLeft: 3, children: [yesFocused ? (_jsx(Text, { color: theme.text.inverse, backgroundColor: theme.bg.active, children: `\u276f ${yesLabel}` })) : (_jsx(Text, { color: theme.text.muted, children: ` ${yesLabel}` })), _jsx(Text, { color: theme.text.muted, children: " " }), cancelFocused ? (_jsx(Text, { color: theme.text.inverse, backgroundColor: theme.bg.active, children: `\u276f ${cancelLabel}` })) : (_jsx(Text, { color: theme.text.muted, children: ` ${cancelLabel}` }))] }));
85
- }
86
79
  // ── Hint bar ──────────────────────────────────────────────────────────
87
80
  function HintBar({ leftHint, rightHint, }) {
88
81
  return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Box, { children: leftHint }), _jsx(Box, { children: rightHint })] }));
@@ -219,54 +212,39 @@ function CommandMode({ context, onCommand, onCopy, onPanelAction, disabled, navO
219
212
  }
220
213
  // ── Confirm mode ─────────────────────────────────────────────────────
221
214
  function ConfirmMode({ confirmState, onConfirmYes, onConfirmCancel, disabled, }) {
222
- const yesLabel = t("cmdInput.confirmYes");
223
- const cancelLabel = t("cmdInput.confirmCancel");
224
- const options = useMemo(() => [
225
- { label: cancelLabel, value: CONFIRM_CANCEL },
226
- { label: yesLabel, value: CONFIRM_YES },
227
- ], [cancelLabel, yesLabel]);
228
- const handleSelect = useCallback((value) => {
229
- if (value === CONFIRM_YES) {
230
- onConfirmYes();
231
- }
232
- else {
233
- onConfirmCancel();
234
- }
235
- }, [onConfirmYes, onConfirmCancel]);
236
- const { state, dispatch } = useAutocompleteState({
237
- options,
238
- visibleOptionCount: 2,
239
- onSelect: handleSelect,
240
- });
241
- useInput((_input, key) => {
215
+ const [value, setValue] = useState("");
216
+ useInput((input, key) => {
242
217
  if (key.ctrl)
243
218
  return;
244
- if (_input.length > 1 && (_input.includes("\r") || _input.includes("\n")))
219
+ if (input.length > 1 && (input.includes("\r") || input.includes("\n")))
245
220
  return;
246
221
  if (key.escape) {
222
+ setValue("");
247
223
  onConfirmCancel();
248
224
  return;
249
225
  }
250
226
  if (key.return) {
251
- if (state.isOpen && state.filteredOptions.length > 0 && state.focusedIndex < state.filteredOptions.length) {
252
- const focused = state.filteredOptions[state.focusedIndex];
253
- dispatch({ type: "SELECT", value: focused.option.value, label: focused.option.label });
227
+ if (value.toLowerCase() === "yes") {
228
+ setValue("");
229
+ onConfirmYes();
254
230
  }
255
231
  else {
232
+ setValue("");
256
233
  onConfirmCancel();
257
234
  }
258
235
  return;
259
236
  }
260
- if (key.downArrow) {
261
- dispatch({ type: "FOCUS_NEXT" });
237
+ if (key.backspace || key.delete) {
238
+ setValue((v) => v.slice(0, -1));
262
239
  return;
263
240
  }
264
- if (key.upArrow) {
265
- dispatch({ type: "FOCUS_PREV" });
241
+ if (input && !key.ctrl && !key.meta && input.length === 1) {
242
+ setValue((v) => v + input);
266
243
  return;
267
244
  }
268
245
  }, { isActive: !disabled });
269
- return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.semantic.danger, children: "│ " }), _jsx(Text, { color: theme.text.primary, children: confirmState.prompt })] }), _jsx(ConfirmInlineOptions, { focusedIndex: state.focusedIndex, yesLabel: yesLabel, cancelLabel: cancelLabel }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "confirm" }) })] }));
246
+ const cursor = "\x1b[7m \x1b[27m";
247
+ return (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.semantic.danger, children: "│ " }), _jsx(Text, { color: theme.text.muted, children: confirmState.prompt + " " }), value.length > 0 ? (_jsx(Text, { children: value + cursor })) : (_jsx(Text, { children: cursor }))] }), _jsx(HintBar, { leftHint: _jsxs(Box, { children: [_jsx(Text, { color: theme.text.muted, children: "\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7 " }), _jsx(KeyHint, { keyLabel: "esc", action: "cancel" })] }), rightHint: _jsx(KeyHint, { keyLabel: "enter", action: "confirm" }) })] }));
270
248
  }
271
249
  // ── Search mode ──────────────────────────────────────────────────────
272
250
  function SearchMode({ value, onSearchChange, onSearchSubmit, onSearchCancel, disabled, }) {
@@ -61,7 +61,7 @@ export function CreateView(props) {
61
61
  hint: t("board.hintTask"),
62
62
  required: true,
63
63
  inputType: "text",
64
- defaultValue: initial.taskId ?? undefined,
64
+ defaultValue: selectedTaskId ?? initial.taskId ?? undefined,
65
65
  skip: (values) => !values.taskMode?.includes("Existing"),
66
66
  onActivate: () => setTaskPickerOpen(true),
67
67
  renderCustom: ({ isActive }) => (_jsx(SelectValueField, { label: resolvedTaskName, placeholder: t("board.chooseTask"), isActive: isActive })),
@@ -106,7 +106,7 @@ export function CreateView(props) {
106
106
  key: "description",
107
107
  prompt: t("wizard.descriptionPrompt"),
108
108
  hint: t("wizard.descriptionHint"),
109
- required: false,
109
+ required: true,
110
110
  inputType: "text",
111
111
  defaultValue: initial.description ?? undefined,
112
112
  },
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback, useMemo } from "react";
2
+ import { useState, useCallback, useMemo, useRef } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { darkTheme as theme } from "../theme.js";
5
5
  import { t } from "../../i18n/index.js";
@@ -28,9 +28,22 @@ export function WizardForm(props) {
28
28
  result[s.key] = values[s.key] ?? s.defaultValue ?? "";
29
29
  return result;
30
30
  }, [steps, values]);
31
+ // Synchronous mirror of resolvedValues — updated inside setValue so
32
+ // findNextField sees the new value immediately, before React re-renders.
33
+ // Without this, onChange("Existing task") + onAdvance() run in the same
34
+ // tick: setValues queues a state update, but findNextField reads stale
35
+ // resolvedValues where taskMode is still "" → taskId is wrongly skipped.
36
+ const resolvedValuesRef = useRef({});
37
+ const computeResolved = (raw) => {
38
+ const result = {};
39
+ for (const s of steps)
40
+ result[s.key] = raw[s.key] ?? s.defaultValue ?? "";
41
+ return result;
42
+ };
31
43
  const setValue = useCallback((key, next) => {
44
+ resolvedValuesRef.current = computeResolved({ ...resolvedValuesRef.current, [key]: next });
32
45
  setValues((prev) => ({ ...prev, [key]: next }));
33
- }, []);
46
+ }, [steps]);
34
47
  const clearError = useCallback((key) => {
35
48
  setValidationErrors((prev) => {
36
49
  if (!(key in prev))
@@ -75,14 +88,15 @@ export function WizardForm(props) {
75
88
  const findNextField = useCallback((from, delta) => {
76
89
  let next = from + delta;
77
90
  const len = steps.length;
91
+ const vals = resolvedValuesRef.current;
78
92
  while (next >= 0 && next < len) {
79
93
  const candidate = steps[next];
80
- if (!candidate.skip || !candidate.skip(resolvedValues))
94
+ if (!candidate.skip || !candidate.skip(vals))
81
95
  return next;
82
96
  next += delta;
83
97
  }
84
98
  return from;
85
- }, [steps, values]);
99
+ }, [steps]);
86
100
  const moveField = useCallback((delta) => {
87
101
  if (step) {
88
102
  const err = validateField(step.key, resolvedValues);
package/dist/tui/index.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import { render } from "ink";
2
2
  import React from "react";
3
- import { App } from "./app.js";
3
+ import { App } from "./App.js";
4
4
  import { BRACKETED_PASTE_ENABLE, BRACKETED_PASTE_DISABLE } from "../config/constants.js";
5
5
  export async function launchBoard() {
6
6
  // Enable bracketed paste so a native paste (Ctrl+Shift+V / Cmd+V / right-click)
7
7
  // arrives as one delimited chunk instead of a stream of keypresses.
8
8
  process.stdout.write(BRACKETED_PASTE_ENABLE);
9
9
  const disableBracketedPaste = () => process.stdout.write(BRACKETED_PASTE_DISABLE);
10
- const instance = render(React.createElement(App, { onQuit: () => {
10
+ const instance = render(React.createElement(App, {
11
+ onQuit: () => {
11
12
  disableBracketedPaste();
12
13
  instance.unmount();
13
- } }));
14
+ }
15
+ }));
14
16
  process.on("exit", disableBracketedPaste);
15
17
  process.on("uncaughtException", (error) => {
16
18
  console.error("Uncaught exception:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loop-task",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Loop engineering toolkit. Run any command on a cadence, in the background, managed from a terminal board. Schedule tests, builds, syncs, or agent prompts.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,7 +72,7 @@
72
72
  "vitest": "^3.1.0"
73
73
  },
74
74
  "scripts": {
75
- "build": "tsc -p tsconfig.build.json && node -e \"require('fs').copyFileSync('src/entry.js','dist/entry.js'); require('fs').copyFileSync('src/esm-loader.js','dist/esm-loader.js')\"",
75
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"require('fs').copyFileSync('src/entry.js','dist/entry.js'); require('fs').copyFileSync('src/esm-loader.js','dist/esm-loader.js')\"",
76
76
  "start": "node dist/entry.js",
77
77
  "dev": "tsx src/cli.ts",
78
78
  "dev:watch": "tsx --watch src/cli.ts",
package/dist/board/App.js DELETED
@@ -1,405 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from "react";
3
- import { applyLoopFilters, cycleSortMode, cycleStatusFilter, defaultFilters, } from "./state.js";
4
- import { ToastStack, useToasts } from "./toast.js";
5
- import { t } from "../i18n/index.js";
6
- import { useLoopPolling } from "./hooks/useLoopPolling.js";
7
- import { useLogStream } from "./hooks/useLogStream.js";
8
- import { useBoardKeybindings } from "./hooks/useBoardKeybindings.js";
9
- import { useTaskKeybindings } from "./hooks/useTaskKeybindings.js";
10
- import { Header } from "./components/Header.js";
11
- import { FilterBar } from "./components/FilterBar.js";
12
- import { Navigator } from "./components/Navigator.js";
13
- import { Inspector } from "./components/Inspector.js";
14
- import { RunHistory } from "./components/RunHistory.js";
15
- import { ActionButtons, getActionKeys } from "./components/ActionButtons.js";
16
- import { HelpModal } from "./components/HelpModal.js";
17
- import { ContextHelpModal } from "./components/ContextHelpModal.js";
18
- import { Footer } from "./components/Footer.js";
19
- import { ConfirmModal } from "./components/ConfirmModal.js";
20
- import { CreateView, createInitialValues } from "./components/CreateForm.js";
21
- import { TaskForm } from "./components/TaskForm.js";
22
- import { TaskNavigator, TaskInspector, TaskActionButtons } from "./components/TaskBrowser.js";
23
- import { TaskFilterBar } from "./components/TaskFilterBar.js";
24
- import { LogModal } from "./components/LogModal.js";
25
- import { ProjectsModal } from "./components/ProjectsModal.js";
26
- import { ProjectsPage } from "./components/ProjectsPage.js";
27
- import { fetchRunLog, deleteLoop, pauseLoop, resumeLoop, stopLoop, playLoop, triggerLoop, listTasks, deleteTask, listProjects } from "./daemon.js";
28
- import { useBreakpoint } from "./hooks/useBreakpoint.js";
29
- import { useRouter } from "./router.js";
30
- import { POLL_MS } from "../config/constants.js";
31
- import { useTabNav } from "./hooks/useTabNav.js";
32
- const VIEW_TO_MODE = {
33
- create: "create",
34
- "task-create": "task",
35
- "task-edit": "task",
36
- "task-list": "task",
37
- projects: "projects",
38
- };
39
- function resolveMode(confirm, searchActive, helpOpen, view) {
40
- if (confirm)
41
- return "confirm";
42
- if (searchActive)
43
- return "search";
44
- if (helpOpen)
45
- return "help";
46
- return VIEW_TO_MODE[view] ?? "normal";
47
- }
48
- function viewKey(view, editTarget, editTask) {
49
- if (view === "create")
50
- return `${view}:${editTarget?.id ?? "new"}`;
51
- if (view === "task-edit")
52
- return `${view}:${editTask?.id ?? "new"}`;
53
- return view;
54
- }
55
- export function App(props) {
56
- const { loops, daemonStatus, refresh } = useLoopPolling();
57
- const { view, stack, push, replace, pop } = useRouter("board");
58
- const [filters, setFilters] = useState(defaultFilters);
59
- const [sort, setSort] = useState("description");
60
- const [selectedIndex, setSelectedIndex] = useState(0);
61
- const [searchActive, setSearchActive] = useState(false);
62
- const [helpOpen, setHelpOpen] = useState(false);
63
- const [contextHelpOpen, setContextHelpOpen] = useState(false);
64
- const [confirm, setConfirm] = useState(null);
65
- const [confirmChoice, setConfirmChoice] = useState(0);
66
- const [editTarget, setEditTarget] = useState(null);
67
- const [cloneMode, setCloneMode] = useState(false);
68
- const [editTask, setEditTask] = useState(null);
69
- const [pendingTaskSelection, setPendingTaskSelection] = useState(null);
70
- const [selectedRunIndex, setSelectedRunIndex] = useState(0);
71
- const [selectedAction, setSelectedAction] = useState(0);
72
- const [logModalRun, setLogModalRun] = useState(null);
73
- const [logModalLines, setLogModalLines] = useState([]);
74
- const [logModalLoading, setLogModalLoading] = useState(false);
75
- const [tasks, setTasks] = useState([]);
76
- const [taskSelectedIndex, setTaskSelectedIndex] = useState(0);
77
- const [taskSelectedAction, setTaskSelectedAction] = useState(0);
78
- const [taskSearchActive, setTaskSearchActive] = useState(false);
79
- const [taskQuery, setTaskQuery] = useState("");
80
- const [projects, setProjects] = useState([]);
81
- const createProjectTriggerRef = useRef(null);
82
- const [currentProjectId, setCurrentProjectId] = useState(() => {
83
- try {
84
- return localStorage.getItem("loop-current-project") ?? "all";
85
- }
86
- catch {
87
- return "all";
88
- }
89
- });
90
- const [projectsModalOpen, setProjectsModalOpen] = useState(false);
91
- const { toasts, push: pushToast } = useToasts();
92
- const breakpoint = useBreakpoint();
93
- const visible = useMemo(() => applyLoopFilters(currentProjectId === "all" ? loops : loops.filter((l) => (l.projectId ?? "default") === currentProjectId), filters, sort), [loops, filters, sort, currentProjectId]);
94
- const clampedIndex = Math.min(selectedIndex, Math.max(0, visible.length - 1));
95
- const selected = visible[clampedIndex] ?? null;
96
- const selectedId = selected?.id ?? null;
97
- const selectedDesc = selected?.description ?? "";
98
- const prevSelectedId = useRef(null);
99
- if (selectedId !== prevSelectedId.current) {
100
- prevSelectedId.current = selectedId;
101
- setSelectedRunIndex(0);
102
- }
103
- useEffect(() => {
104
- setSelectedAction(0);
105
- }, [selected?.id]);
106
- const { destroy: destroyLogSocket } = useLogStream(selectedId, view, (error) => pushToast("error", t("board.logStreamError", { message: error.message })));
107
- const filteredTasks = useMemo(() => {
108
- if (!taskQuery)
109
- return tasks;
110
- const q = taskQuery.toLowerCase();
111
- return tasks.filter((t) => `${t.id} ${t.name} ${t.command}`.toLowerCase().includes(q));
112
- }, [tasks, taskQuery]);
113
- const taskClampedIndex = Math.min(taskSelectedIndex, Math.max(0, filteredTasks.length - 1));
114
- const selectedTask = filteredTasks[taskClampedIndex] ?? null;
115
- async function refreshTasks() {
116
- try {
117
- setTasks(await listTasks());
118
- }
119
- catch { /* ignore */ }
120
- }
121
- async function refreshProjects() {
122
- try {
123
- setProjects(await listProjects());
124
- }
125
- catch { /* ignore */ }
126
- }
127
- useEffect(() => { void refreshTasks(); void refreshProjects(); }, []);
128
- useEffect(() => {
129
- const timer = setInterval(() => {
130
- void refreshTasks();
131
- void refreshProjects();
132
- }, POLL_MS);
133
- return () => clearInterval(timer);
134
- }, []);
135
- useEffect(() => {
136
- try {
137
- localStorage.setItem("loop-current-project", currentProjectId);
138
- }
139
- catch { }
140
- }, [currentProjectId]);
141
- function runAction(label, action) {
142
- return async () => {
143
- try {
144
- await action();
145
- void refresh();
146
- pushToast("success", label);
147
- }
148
- catch (error) {
149
- pushToast("error", error instanceof Error ? error.message : String(error));
150
- }
151
- };
152
- }
153
- function handleOpenRunLog(run) {
154
- if (!selectedId)
155
- return;
156
- setLogModalRun(run);
157
- if (run.status === "running") {
158
- setLogModalLoading(false);
159
- setLogModalLines([]);
160
- return;
161
- }
162
- setLogModalLoading(true);
163
- setLogModalLines([]);
164
- fetchRunLog(selectedId, run.runNumber)
165
- .then((log) => {
166
- setLogModalLines(log ? log.split("\n") : []);
167
- setLogModalLoading(false);
168
- })
169
- .catch(() => {
170
- setLogModalLines([]);
171
- setLogModalLoading(false);
172
- });
173
- }
174
- function confirmAction(msg, toast, fn) {
175
- setConfirmChoice(0);
176
- setConfirm({ message: msg, action: runAction(toast, fn) });
177
- }
178
- function handleAction(action) {
179
- if (!selected || !selectedId)
180
- return;
181
- if (action === "edit") {
182
- setEditTarget(selected);
183
- push("create");
184
- return;
185
- }
186
- if (action === "clone") {
187
- const copy = {
188
- ...selected,
189
- id: "",
190
- description: `${selected.description} - Copy`,
191
- };
192
- setEditTarget(copy);
193
- setCloneMode(true);
194
- push("create");
195
- return;
196
- }
197
- if (action === "delete") {
198
- confirmAction(t("board.confirmDelete", { desc: selectedDesc }), t("board.toastDeleted", { desc: selectedDesc }), () => deleteLoop(selectedId));
199
- return;
200
- }
201
- if (action === "pause-or-play") {
202
- if (selected.status === "waiting") {
203
- confirmAction(t("board.confirmPause", { desc: selectedDesc }), t("board.toastPaused", { desc: selectedDesc }), () => pauseLoop(selectedId));
204
- }
205
- else if (selected.status === "paused") {
206
- confirmAction(t("board.confirmResume", { desc: selectedDesc }), t("board.toastResumed", { desc: selectedDesc }), () => resumeLoop(selectedId));
207
- }
208
- else {
209
- confirmAction(t("board.confirmPlay", { desc: selectedDesc }), t("board.toastPlayed", { desc: selectedDesc }), () => playLoop(selectedId));
210
- }
211
- return;
212
- }
213
- if (action === "stop") {
214
- confirmAction(t("board.confirmStop", { desc: selectedDesc }), t("board.toastStopped", { desc: selectedDesc }), () => stopLoop(selectedId));
215
- return;
216
- }
217
- if (action === "play") {
218
- const isPaused = selected.status === "paused";
219
- const msgKey = isPaused ? "board.confirmResume" : "board.confirmPlay";
220
- const toastKey = isPaused ? "board.toastResumed" : "board.toastPlayed";
221
- const fn = isPaused ? () => resumeLoop(selectedId) : () => playLoop(selectedId);
222
- confirmAction(t(msgKey, { desc: selectedDesc }), t(toastKey, { desc: selectedDesc }), fn);
223
- return;
224
- }
225
- if (action === "trigger") {
226
- confirmAction(t("board.confirmTrigger", { desc: selectedDesc }), t("board.toastTriggered", { desc: selectedDesc }), () => triggerLoop(selectedId));
227
- return;
228
- }
229
- }
230
- function handleTaskAction(action) {
231
- if (!selectedTask)
232
- return;
233
- if (action === "select") {
234
- if (!stack.includes("create") && !stack.includes("task-edit"))
235
- return;
236
- setPendingTaskSelection({ id: selectedTask.id, name: selectedTask.name });
237
- pop();
238
- pushToast("success", t("board.toastTaskSelected", { desc: selectedTask.name }));
239
- return;
240
- }
241
- if (action === "edit") {
242
- setEditTask(selectedTask);
243
- push("task-edit");
244
- return;
245
- }
246
- if (action === "delete") {
247
- setConfirmChoice(0);
248
- setConfirm({
249
- message: t("board.confirmDeleteTask", { desc: selectedTask.name }),
250
- action: async () => {
251
- await deleteTask(selectedTask.id);
252
- await refreshTasks();
253
- setTaskSelectedIndex((i) => Math.max(0, i - 1));
254
- pushToast("success", t("board.toastTaskDeleted", { desc: selectedTask.name }));
255
- },
256
- });
257
- }
258
- }
259
- const cancelCreate = () => { setEditTarget(null); setCloneMode(false); setPendingTaskSelection(null); pop(); };
260
- const cancelTask = () => { setEditTask(null); pop(); };
261
- const cancelTaskList = () => pop();
262
- function handleChooseTask() {
263
- void refreshTasks();
264
- push("task-list");
265
- }
266
- function handleCreateTask() {
267
- setEditTask(null);
268
- push("task-create");
269
- }
270
- const onCreateDone = (updated, _id, desc) => {
271
- setEditTarget(null);
272
- setCloneMode(false);
273
- setPendingTaskSelection(null);
274
- pop();
275
- pushToast("success", updated ? t("board.toastUpdated", { desc }) : t("board.toastStarted", { desc }));
276
- void refresh();
277
- };
278
- const onTaskDone = (updated, id) => {
279
- setEditTask(null);
280
- void refreshTasks();
281
- pop();
282
- pushToast("success", updated ? t("board.toastTaskUpdated", { id }) : t("board.toastTaskCreated", { id }));
283
- };
284
- const FORM_VIEWS = ["create", "task-create", "task-edit"];
285
- const isFormView = FORM_VIEWS.includes(view);
286
- const actionKeys = selected ? getActionKeys(selected.status) : [];
287
- const actionItems = actionKeys.map((k) => `action-${k}`);
288
- const boardItems = ["search", "project-filter", "status", "sort", "header-1", "header-2", "header-3", "navigator", "run-history", ...actionItems];
289
- const taskItems = ["task-search", "header-1", "header-2", "header-3", "task-list", ...(["select", "edit", "delete"].map((k) => `task-action-${k}`))];
290
- const projectItems = ["header-1", "header-2", "header-3"];
291
- const navItems = isFormView ? [] : view === "task-list" ? taskItems : view === "projects" ? projectItems : boardItems;
292
- const { focusedItem: navFocused, setFocusIndex: setNavIndex, enabled: navEnabled } = useTabNav(navItems);
293
- const HEADER_MAP = { "header-1": "header-tasks", "header-2": "header-projects", "header-3": "header-new" };
294
- const isHeaderFocused = navFocused === "header-1" || navFocused === "header-2" || navFocused === "header-3";
295
- const derivedFocusedPanel = isHeaderFocused ? HEADER_MAP[navFocused] : navFocused ?? "loops";
296
- const actionIdx = navFocused?.startsWith("action-") ? actionKeys.indexOf(navFocused.slice("action-".length)) : -1;
297
- const derivedSelectedAction = actionIdx >= 0 ? actionIdx : selectedAction;
298
- const taskActionIdx = navFocused?.startsWith("task-action-") ? ["select", "edit", "delete"].indexOf(navFocused.slice("task-action-".length)) : -1;
299
- const derivedTaskAction = taskActionIdx >= 0 ? taskActionIdx : taskSelectedAction;
300
- const taskPanelMap = {
301
- "task-search": "search",
302
- "task-list": "tasks",
303
- "task-action-select": "actions",
304
- "task-action-edit": "actions",
305
- "task-action-delete": "actions",
306
- };
307
- const derivedTaskPanel = (navFocused ? taskPanelMap[navFocused] : undefined) ?? "tasks";
308
- useBoardKeybindings({
309
- confirm,
310
- confirmChoice,
311
- setConfirm,
312
- setConfirmChoice,
313
- helpOpen,
314
- setHelpOpen,
315
- searchActive,
316
- setSearchActive,
317
- view,
318
- push,
319
- pop,
320
- setEditTarget,
321
- setEditTask,
322
- selected,
323
- visibleCount: visible.length,
324
- setSelectedIndex,
325
- setFilters,
326
- setSort,
327
- onQuit: props.onQuit,
328
- destroyLogSocket,
329
- logModalRun,
330
- setLogModalRun,
331
- logModalLines,
332
- selectedRunIndex,
333
- setSelectedRunIndex,
334
- selectedRunCount: selected?.runHistory?.length ?? 0,
335
- focusedPanel: derivedFocusedPanel,
336
- setFocusedPanel: ((p) => {
337
- const navIdx = navItems.indexOf(p);
338
- if (navIdx >= 0)
339
- setNavIndex(navIdx);
340
- }),
341
- selectedAction: derivedSelectedAction,
342
- setSelectedAction,
343
- onAction: handleAction,
344
- onOpenRunLog: handleOpenRunLog,
345
- refreshTasks,
346
- onViewTasks: () => { void refreshTasks(); push("task-list"); },
347
- onViewProjects: () => { void refreshProjects(); push("projects"); },
348
- onViewLoops: () => replace("board"),
349
- onAddLoop: () => { setEditTarget(null); push("create"); },
350
- onAddTask: () => { setEditTask(null); push("task-create"); },
351
- onSelectProject: () => setProjectsModalOpen(true),
352
- });
353
- useTaskKeybindings({
354
- confirm,
355
- view,
356
- tasks: filteredTasks,
357
- taskSelectedIndex,
358
- setTaskSelectedIndex,
359
- taskSelectedAction: derivedTaskAction,
360
- setTaskSelectedAction: ((updater) => {
361
- const nextIdx = typeof updater === "function" ? updater(derivedTaskAction) : updater;
362
- const taskActions = ["select", "edit", "delete"];
363
- const actionKey = taskActions[nextIdx];
364
- if (actionKey) {
365
- const navIdx = navItems.indexOf(`task-action-${actionKey}`);
366
- if (navIdx >= 0)
367
- setNavIndex(navIdx);
368
- }
369
- }),
370
- taskFocusedPanel: derivedTaskPanel,
371
- setTaskFocusedPanel: ((p) => {
372
- const navIdx = navItems.indexOf(p === "search" ? "task-search" : p === "actions" ? "task-actions" : "task-list");
373
- if (navIdx >= 0)
374
- setNavIndex(navIdx);
375
- }),
376
- taskSearchActive,
377
- setTaskSearchActive,
378
- taskQuery,
379
- setTaskQuery,
380
- onTaskAction: handleTaskAction,
381
- onCancel: cancelTaskList,
382
- onCreateTask: handleCreateTask,
383
- onToggleContextHelp: () => setContextHelpOpen((v) => !v),
384
- headerFocused: isHeaderFocused,
385
- selectable: stack.includes("create") || stack.includes("task-edit"),
386
- });
387
- const counts = {
388
- total: loops.length,
389
- running: loops.filter((l) => l.status === "running").length,
390
- waiting: loops.filter((l) => l.status === "waiting").length,
391
- paused: loops.filter((l) => l.status === "paused").length,
392
- idle: loops.filter((l) => l.status === "idle").length,
393
- };
394
- const mode = resolveMode(confirm, searchActive || taskSearchActive, helpOpen, view);
395
- return (_jsxs("box", { style: { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#0b0b0b" }, children: [_jsx(Header, { daemonStatus: daemonStatus, counts: counts, view: view, focusedPanel: derivedFocusedPanel, onViewLoops: () => replace("board"), onViewTasks: () => { void refreshTasks(); push("task-list"); }, onViewProjects: () => push("projects"), onAddLoop: () => { setEditTarget(null); push("create"); }, onAddTask: () => { setEditTask(null); push("task-create"); }, onAddProject: () => { if (view === "projects") {
396
- createProjectTriggerRef.current?.();
397
- }
398
- else {
399
- push("projects");
400
- } } }), view === "board" ? (_jsx(FilterBar, { filters: filters, sort: sort, searchActive: searchActive, focusedPanel: derivedFocusedPanel, onStatusCycle: () => setFilters((prev) => ({ ...prev, status: cycleStatusFilter(prev.status) })), onSortCycle: () => setSort(cycleSortMode(sort)), onSelectProject: () => setProjectsModalOpen(true), currentProjectName: currentProjectId === "all" ? t("project.showAll") : (projects.find(p => p.id === currentProjectId)?.name ?? "Default"), onQueryChange: (value) => setFilters((prev) => ({ ...prev, query: value })), onSearchActivate: () => setSearchActive(true), onSearchDismiss: () => { setSearchActive(false); } })) : view === "task-list" ? (_jsx(TaskFilterBar, { query: taskQuery, searchActive: taskSearchActive, focusedPanel: derivedTaskPanel, onQueryChange: setTaskQuery, onSearchActivate: () => setTaskSearchActive(true), onSearchDismiss: () => { setTaskSearchActive(false); } })) : null, _jsx("box", { style: { flexGrow: 1, backgroundColor: "#0b0b0b" }, children: view === "create" ? (_jsx(CreateView, { mode: editTarget && !cloneMode ? "edit" : "create", editId: editTarget && !cloneMode ? editTarget.id : null, initial: createInitialValues(editTarget, currentProjectId), selectedTaskId: pendingTaskSelection?.id ?? null, selectedTaskName: pendingTaskSelection?.name ?? null, projects: projects, currentProjectId: currentProjectId, onCancel: cancelCreate, onDone: onCreateDone, onChooseTask: handleChooseTask })) : TASK_FORM_VIEWS.has(view) ? (_jsx(TaskForm, { mode: view === "task-edit" ? "edit" : "create", editTask: editTask, onCancel: cancelTask, onDone: onTaskDone, onCopy: () => pushToast("success", t("board.toastCopied")) })) : view === "task-list" ? (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(TaskNavigator, { visible: filteredTasks, total: tasks.length, selectedIndex: taskClampedIndex, focused: derivedTaskPanel === "tasks", query: taskQuery, onSelect: (index) => { setTaskSelectedIndex(index); }, onActivate: (index) => { setTaskSelectedIndex(index); setEditTask(filteredTasks[index] ?? null); push("task-edit"); } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(TaskInspector, { task: selectedTask }, `ti-${selectedTask?.id}`), _jsx(TaskActionButtons, { task: selectedTask, focused: taskActionIdx >= 0, selectedAction: derivedTaskAction, selectable: stack.includes("create") || stack.includes("task-edit"), onAction: handleTaskAction }, `tab-${selectedTask?.id}`)] })] })) : view === "projects" ? (_jsx(ProjectsPage, { projects: projects, loops: loops, headerFocused: isHeaderFocused, onClose: () => pop(), onRefresh: refreshProjects, onOpenCreate: (trigger) => { createProjectTriggerRef.current = trigger; }, onEnterHeader: () => { } })) : (_jsxs("box", { style: { flexDirection: breakpoint === "narrow" ? "column" : "row", flexGrow: 1, backgroundColor: "#0b0b0b" }, children: [_jsx(Navigator, { visible: visible, total: loops.length, selectedIndex: clampedIndex, filters: filters, sort: sort, breakpoint: breakpoint, focused: derivedFocusedPanel === "loops" || derivedFocusedPanel === "navigator", projects: projects, onSelect: (index) => setSelectedIndex(index), onActivate: (index) => { setSelectedIndex(index); const loop = visible[index]; if (loop) {
401
- setEditTarget(loop);
402
- push("create");
403
- } } }), _jsxs("box", { style: { flexDirection: "column", flexGrow: 1, backgroundColor: "#0b0b0b", overflow: "hidden" }, children: [_jsx(Inspector, { loop: selected }), _jsx(RunHistory, { loop: selected, selectedRunIndex: selectedRunIndex, focused: derivedFocusedPanel === "runs" || derivedFocusedPanel === "run-history", onSelectRun: (index) => setSelectedRunIndex(index), onOpenRun: handleOpenRunLog }), _jsx(ActionButtons, { loop: selected, focused: actionIdx >= 0, selectedAction: derivedSelectedAction, onAction: handleAction })] })] })) }, viewKey(view, editTarget, editTask)), _jsx(Footer, { mode: mode }), confirm ? (_jsx(ConfirmModal, { message: confirm.message, choice: confirmChoice, onYes: () => { const action = confirm.action; setConfirm(null); void action(); }, onNo: () => setConfirm(null) })) : null, helpOpen ? _jsx(HelpModal, { view: view }) : null, contextHelpOpen ? _jsx(ContextHelpModal, { onClose: () => setContextHelpOpen(false) }) : null, logModalRun ? (_jsx(LogModal, { loopId: selectedId, run: logModalRun, logLines: logModalLines, loading: logModalLoading, onClose: () => setLogModalRun(null) })) : null, projectsModalOpen ? (_jsx(ProjectsModal, { projects: projects, loops: loops, currentProjectId: currentProjectId, onSelect: (id) => { setCurrentProjectId(id); setProjectsModalOpen(false); }, onClose: () => setProjectsModalOpen(false) })) : null, _jsx(ToastStack, { toasts: toasts })] }));
404
- }
405
- const TASK_FORM_VIEWS = new Set(["task-create", "task-edit"]);