@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21

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 (112) hide show
  1. package/README.md +28 -1
  2. package/app/api/action/route.ts +39 -3
  3. package/app/api/action-logs/route.ts +24 -0
  4. package/app/api/backup/route.ts +1 -1
  5. package/app/api/restore/route.ts +145 -0
  6. package/app/changelog/page.tsx +71 -4
  7. package/app/globals.css +127 -0
  8. package/app/guide/page.tsx +61 -15
  9. package/app/implementation/page.tsx +700 -0
  10. package/app/layout.tsx +14 -3
  11. package/app/licenses/page.tsx +99 -37
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +784 -229
  15. package/app/reporting/page.tsx +1266 -474
  16. package/app/settings/page.tsx +252 -18
  17. package/bin/kronosys.mjs +140 -15
  18. package/components/KronosysPayloadProvider.tsx +2 -0
  19. package/components/RouteTransition.tsx +18 -0
  20. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  21. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  22. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  23. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  24. package/components/dashboard/AppShellRouteNav.tsx +323 -48
  25. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  26. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  27. package/components/dashboard/DashboardTour.tsx +115 -29
  28. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  29. package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
  30. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  31. package/components/dashboard/NewSessionScopeModal.tsx +211 -20
  32. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  33. package/components/dashboard/ReportingTour.tsx +87 -21
  34. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  35. package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
  36. package/components/dashboard/SessionListPanel.tsx +327 -44
  37. package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
  38. package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
  39. package/components/dashboard/SettingsTour.tsx +86 -21
  40. package/components/dashboard/TagPills.tsx +14 -1
  41. package/components/dashboard/TaskFocusPanel.tsx +1081 -478
  42. package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
  43. package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
  44. package/components/dashboard/taskFieldStyles.ts +20 -4
  45. package/components/dashboard/useReportingInteractionState.ts +80 -0
  46. package/lib/appShellHeaderClasses.ts +13 -0
  47. package/lib/businessRulesMatrix.ts +210 -0
  48. package/lib/copyToClipboard.ts +43 -0
  49. package/lib/dashboardCopy.ts +494 -84
  50. package/lib/dashboardQuickSearch.ts +54 -2
  51. package/lib/dashboardTimeZone.ts +109 -0
  52. package/lib/formatAppShellWallClock.ts +66 -0
  53. package/lib/formatSessionNameTemplate.ts +141 -0
  54. package/lib/generatedUserChangelog.ts +177 -6
  55. package/lib/globalPausePreview.ts +292 -0
  56. package/lib/implementationNotes.ts +1188 -0
  57. package/lib/kronosysApi.ts +6 -0
  58. package/lib/kronosysDashboardModalGates.ts +24 -0
  59. package/lib/plannedBoundaryAttention.ts +9 -0
  60. package/lib/plannedBoundaryConflict.ts +23 -0
  61. package/lib/reportingAggregate.ts +517 -75
  62. package/lib/reportingMetricHelp.ts +8 -0
  63. package/lib/reportingStrings.ts +37 -3
  64. package/lib/sessionListMerge.ts +4 -0
  65. package/lib/sessionTaskSidebarStats.ts +182 -21
  66. package/lib/settingsCopy.ts +178 -4
  67. package/lib/taskParsing.ts +360 -103
  68. package/lib/taskTemplateDraft.ts +135 -0
  69. package/lib/taskTimelineGantt.ts +265 -0
  70. package/lib/temporalDisplayPlanned.ts +71 -0
  71. package/lib/userGuideCopy.ts +121 -47
  72. package/next.config.ts +7 -0
  73. package/package.json +12 -24
  74. package/server/actionDispatch.ts +1000 -77
  75. package/server/actionTaskSession.ts +337 -24
  76. package/server/db.ts +7 -15
  77. package/server/dbSchema.ts +24 -0
  78. package/server/defaultCfg.ts +5 -0
  79. package/server/gitlabTokenStore.ts +0 -12
  80. package/server/liveHistorySync.ts +53 -0
  81. package/server/mainTimerHydrate.ts +38 -2
  82. package/server/payloadStore.ts +33 -11
  83. package/server/sessionWallHydrate.ts +66 -3
  84. package/server/userActionLog.ts +126 -0
  85. package/sonar-project.properties +11 -0
  86. package/tsconfig.json +2 -1
  87. package/components/dashboard/IssuePickerModal.tsx +0 -168
  88. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  89. package/lib/backupCsvExport.test.ts +0 -149
  90. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  91. package/lib/dataDir.test.ts +0 -87
  92. package/lib/formatIsoShort.test.ts +0 -46
  93. package/lib/kronoFocusRhythm.test.ts +0 -130
  94. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  95. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  96. package/lib/reportingAggregate.test.ts +0 -325
  97. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  98. package/lib/reportingTagWeekBreakdown.test.ts +0 -141
  99. package/lib/reportingWeekLayout.test.ts +0 -239
  100. package/lib/sessionAssiduity.test.ts +0 -25
  101. package/lib/sessionEndWarnings.test.ts +0 -200
  102. package/lib/sessionListMerge.test.ts +0 -101
  103. package/lib/sessionTaskSidebarStats.test.ts +0 -24
  104. package/lib/taskParsing.test.ts +0 -153
  105. package/lib/usageProfile.test.ts +0 -84
  106. package/server/actionDispatch.test.ts +0 -723
  107. package/server/actionTaskSession.test.ts +0 -713
  108. package/server/kronoFocusHydrate.test.ts +0 -142
  109. package/server/kronoFocusMigrate.test.ts +0 -53
  110. package/server/mainTimerHydrate.test.ts +0 -65
  111. package/server/payloadStore.test.ts +0 -78
  112. package/server/sessionWallHydrate.test.ts +0 -46
@@ -1,8 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { Timer, Play, Pause, Ticket, Zap, History } from "lucide-react";
5
- import { TRACE_ISSUE_SEARCH_CLIENT_TIMEOUT_MS } from "@/lib/gitlabIssueSearch";
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type MutableRefObject,
10
+ } from "react";
11
+ import { Timer, Play, Pause, Zap, History } from "lucide-react";
6
12
  import { postKronosysAction } from "@/lib/kronosysApi";
7
13
  import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
8
14
  import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
@@ -10,18 +16,23 @@ import {
10
16
  buildStartTaskFromDraft,
11
17
  formatProjectDisplay,
12
18
  mergeTagsForDisplay,
19
+ normalizeProjectKey,
20
+ normalizeTagKey,
13
21
  parseTaskWithAutoTags,
14
- removeProjectFromDraft,
15
- removeSavedTagFromDraft,
22
+ taskTitleForDisplay,
16
23
  } from "@/lib/taskParsing";
24
+ import { isTaskDisplayPlanned } from "@/lib/temporalDisplayPlanned";
17
25
  import { TagPills } from "./TagPills";
18
26
  import {
19
27
  PROJECT_CHIP_APPLIED_CLASS,
28
+ PROJECT_CHIP_APPLIED_PERSONAL_CLASS,
20
29
  TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS,
21
30
  } from "./taskFieldStyles";
22
- import { KronosysDatetimePopoverField } from "./KronosysDatetimePopoverField";
31
+ import {
32
+ KronosysDatetimePopoverField,
33
+ formatDatetimeLocalValue,
34
+ } from "./KronosysDatetimePopoverField";
23
35
  import { TaskSessionLiveCard } from "./TaskSessionLiveCard";
24
- import { IssuePickerModal, type RemoteIssue } from "./IssuePickerModal";
25
36
  import {
26
37
  DashboardAlertModal,
27
38
  DashboardConfirmModal,
@@ -38,23 +49,60 @@ import {
38
49
  writeConcurrentTaskStartPreference,
39
50
  type ConcurrentTaskStartPreference,
40
51
  } from "@/lib/concurrentTaskStartPreference";
52
+ import {
53
+ isPlannedBoundaryConflictModalOpen,
54
+ setConcurrentTaskStartConflictModalOpen,
55
+ } from "@/lib/kronosysDashboardModalGates";
41
56
  import { mergeLiveSessionIntoHistory } from "@/lib/sessionListMerge";
42
- import { formatSessionEndReasonLine } from "@/lib/sessionEndReason";
57
+ import {
58
+ buildTaskTemplateSignature,
59
+ formatTaskTemplateDatalistLabel,
60
+ formatTaskTemplateDraftLine,
61
+ parseTaskTemplatesFromPayload,
62
+ } from "@/lib/taskTemplateDraft";
43
63
  import type { SessionListEntry } from "@/components/dashboard/SessionListPanel";
44
- import { SessionEndReasonEditor } from "@/components/dashboard/SessionEndReasonEditor";
45
64
  import { useDashboardToast } from "@/components/dashboard/DashboardToastProvider";
46
-
47
65
  type TaskEntryMode = "realtime" | "past";
66
+ const TASK_DELETE_ANIM_MS = 170;
67
+ const SAVE_TEMPLATE_MARKER_RE = /(^|\s)##(?=\s|$)/g;
68
+
69
+ function parseDraftTemplateMarker(raw: string): {
70
+ normalizedDraft: string;
71
+ saveAsTemplate: boolean;
72
+ } {
73
+ const hasMarker = SAVE_TEMPLATE_MARKER_RE.test(raw);
74
+ SAVE_TEMPLATE_MARKER_RE.lastIndex = 0;
75
+ if (!hasMarker) {
76
+ return { normalizedDraft: raw.trim(), saveAsTemplate: false };
77
+ }
78
+ const cleaned = raw
79
+ .replace(SAVE_TEMPLATE_MARKER_RE, " ")
80
+ .replace(/\s+/g, " ")
81
+ .trim();
82
+ if (cleaned.length > 0) {
83
+ return { normalizedDraft: cleaned, saveAsTemplate: true };
84
+ }
85
+ // Cas explicite demandé: "##" seul déclenche aussi la sauvegarde template.
86
+ return { normalizedDraft: raw.trim(), saveAsTemplate: true };
87
+ }
48
88
 
49
89
  type TaskRow = {
50
90
  id: string;
51
91
  name: string;
92
+ note?: string;
52
93
  durationMs: number;
53
94
  isDone: boolean;
54
95
  startTime?: string;
55
96
  endTime?: string;
97
+ scheduledEndAt?: string;
98
+ taskTimerLaps?: Array<{
99
+ startTime?: string;
100
+ endTime?: string;
101
+ durationMs?: number;
102
+ }>;
56
103
  tags?: string[];
57
104
  project?: string | null;
105
+ personalProject?: boolean;
58
106
  subtasks?: Array<{
59
107
  id: string;
60
108
  title: string;
@@ -63,6 +111,9 @@ type TaskRow = {
63
111
  }>;
64
112
  manualTaskTimerPaused?: boolean;
65
113
  activeSubtaskTimerId?: string;
114
+ subtaskTimerStartedAt?: string | number;
115
+ mainTimerSegmentStartedAt?: string | null;
116
+ taskCurrentLapStartedAt?: string | number | null;
66
117
  };
67
118
 
68
119
  type SessionShape = {
@@ -78,6 +129,33 @@ type SessionShape = {
78
129
  tasks?: TaskRow[];
79
130
  };
80
131
 
132
+ /** Aligné sur l’arrondi d’affichage des ajustements de durée (TaskSessionLiveCard). */
133
+ function pastEntrySecondsToManualFieldParts(totalSeconds: number): {
134
+ h: string;
135
+ m: string;
136
+ s: string;
137
+ } {
138
+ let ts = Math.max(0, Math.floor(totalSeconds));
139
+ if (ts >= 3600) {
140
+ ts = Math.floor(ts / 60) * 60;
141
+ }
142
+ const h = Math.floor(ts / 3600);
143
+ const m = Math.floor((ts % 3600) / 60);
144
+ const s = ts % 60;
145
+ return { h: String(h), m: String(m), s: String(s) };
146
+ }
147
+
148
+ function parsePastManualDurationMs(
149
+ hours: string,
150
+ minutes: string,
151
+ seconds: string,
152
+ ): number {
153
+ const h = Math.max(0, Math.floor(Number(hours) || 0));
154
+ const m = Math.max(0, Math.floor(Number(minutes) || 0));
155
+ const s = Math.max(0, Math.floor(Number(seconds) || 0));
156
+ return (h * 3600 + m * 60 + s) * 1000;
157
+ }
158
+
81
159
  function runningTasksFromSession(
82
160
  session: SessionShape | null | undefined,
83
161
  ): TaskRow[] {
@@ -88,8 +166,8 @@ function runningTasksFromSession(
88
166
  Array.isArray(session.activeTasks) && session.activeTasks.length > 0
89
167
  ? session.activeTasks
90
168
  : session.activeTask
91
- ? [session.activeTask]
92
- : [];
169
+ ? [session.activeTask]
170
+ : [];
93
171
  return raw.filter((t) => t && !t.isDone && !t.manualTaskTimerPaused);
94
172
  }
95
173
 
@@ -110,6 +188,7 @@ export function TaskFocusPanel({
110
188
  showKronoFocusInTaskOps = true,
111
189
  allowTaskStartTimeEdit = true,
112
190
  allowTaskEndTimeEdit = true,
191
+ taskLauncherApplyDraftRef,
113
192
  }: {
114
193
  payload: KronosysUpdatePayload;
115
194
  lang: Lang;
@@ -128,6 +207,8 @@ export function TaskFocusPanel({
128
207
  allowTaskStartTimeEdit?: boolean;
129
208
  /** Option `dashboardAllowTaskEndTimeEdit` : correction de l'heure de fin des tâches terminées. */
130
209
  allowTaskEndTimeEdit?: boolean;
210
+ /** Remplissage du champ nouvelle tâche depuis la recherche rapide (modèles). */
211
+ taskLauncherApplyDraftRef?: MutableRefObject<((text: string) => void) | null>;
131
212
  }) {
132
213
  const live = payload.current as SessionShape | undefined;
133
214
  const history = useMemo(
@@ -155,48 +236,51 @@ export function TaskFocusPanel({
155
236
 
156
237
  const knownTags = (payload.knownTags || []) as string[];
157
238
  const knownProjects = (payload.knownProjects || []) as string[];
239
+ const knownPersonalProjects = (
240
+ ((payload as Record<string, unknown>).knownPersonalProjects || []) as string[]
241
+ ).filter((x) => typeof x === "string" && x.trim().length > 0);
158
242
  const viewingSession = archiveColumnId
159
- ? (history.find((s) => s.sessionId === archiveColumnId) ??
243
+ ? history.find((s) => s.sessionId === archiveColumnId) ??
160
244
  historyArchivedList.find((s) => s.sessionId === archiveColumnId) ??
161
- null)
245
+ null
162
246
  : null;
163
247
  const sessionCurrent = viewingSession ?? live;
164
248
  const isInspecting = !!viewingSession;
165
249
  const inspectingId = archiveColumnId;
166
250
  const isPauseState = !!live?.isPaused;
167
- const inspectSessionEndReasonLine = viewingSession
168
- ? formatSessionEndReasonLine(
169
- t,
170
- viewingSession.sessionEndReasonKind,
171
- viewingSession.sessionEndReasonNote,
172
- )
173
- : null;
251
+ /** Pause globale : bandeau mural distinct (icône navigation). */
252
+ const globalPauseContextActive = Boolean(
253
+ live &&
254
+ typeof live === "object" &&
255
+ "globalPauseContext" in live &&
256
+ (live as { globalPauseContext?: unknown }).globalPauseContext,
257
+ );
174
258
  /** Session passée **terminée** : on n’affiche que les entrées marquées terminées (pas de blocs en cours / en pause). */
175
259
  const inspectingEndedSession =
176
260
  isInspecting &&
177
261
  typeof viewingSession?.endAt === "string" &&
178
262
  viewingSession.endAt.trim() !== "";
179
- const inspectingLiveRunning =
180
- isInspecting &&
181
- typeof archiveColumnId === "string" &&
182
- typeof liveId === "string" &&
183
- archiveColumnId === liveId &&
184
- hasLiveSession &&
185
- !inspectingEndedSession;
186
- const sessionEndReasonEditSid =
187
- typeof viewingSession?.sessionId === "string"
188
- ? viewingSession.sessionId.trim()
189
- : "";
190
- const canEditInspectSessionEndReason =
191
- isInspecting && !inspectingLiveRunning && sessionEndReasonEditSid !== "";
263
+
264
+ const [bucketNowMs, setBucketNowMs] = useState(() => Date.now());
192
265
 
193
266
  const taskSessionForBuckets = viewingSession ?? live;
194
- /** Tâches live au minuteur (hors archive). */
195
- const runningTasks = !isInspecting ? runningTasksFromSession(live) : [];
267
+ /** Tâches live sur la pile (minuteur non en pause manuelle), avant filtre « planifié ». */
268
+ const runningTasksRaw = !isInspecting ? runningTasksFromSession(live) : [];
269
+ const runningTasks = useMemo(
270
+ () => runningTasksRaw.filter((t) => !isTaskDisplayPlanned(t, bucketNowMs)),
271
+ [runningTasksRaw, bucketNowMs],
272
+ );
196
273
  const runningTaskIds = useMemo(
197
274
  () => new Set(runningTasks.map((t) => String(t.id))),
198
275
  [runningTasks],
199
276
  );
277
+
278
+ useEffect(() => {
279
+ const id = globalThis.setInterval(() => {
280
+ setBucketNowMs(Date.now());
281
+ }, 1000);
282
+ return () => globalThis.clearInterval(id);
283
+ }, []);
200
284
  /**
201
285
  * `tasks` + pile `activeTasks` / `activeTask` (dédoublonné par id, la pile l’emporte).
202
286
  * Sinon une tâche uniquement sur la pile — après pause concurrente — n’apparaît ni « en cours » ni « en pause ».
@@ -217,8 +301,8 @@ export function TaskFocusPanel({
217
301
  Array.isArray(sess.activeTasks) && sess.activeTasks.length > 0
218
302
  ? [...(sess.activeTasks as TaskRow[])]
219
303
  : sess.activeTask
220
- ? [sess.activeTask as TaskRow]
221
- : [];
304
+ ? [sess.activeTask as TaskRow]
305
+ : [];
222
306
  for (const t of stack) {
223
307
  if (t?.id) {
224
308
  map.set(String(t.id), t);
@@ -234,69 +318,190 @@ export function TaskFocusPanel({
234
318
  const trackingActive =
235
319
  !isInspecting && runningTasks.length > 0 && !isPauseState;
236
320
 
237
- const { pausedTasks, completedTasks } = useMemo(() => {
321
+ const { plannedTasks, pausedTasks, completedTasks } = useMemo(() => {
322
+ const planned: TaskRow[] = [];
238
323
  const paused: TaskRow[] = [];
239
324
  const completed: TaskRow[] = [];
240
325
  for (const t of mergedTasksForBuckets) {
241
- if (t.isDone) {
326
+ if (isTaskDisplayPlanned(t, bucketNowMs)) {
327
+ planned.push(t);
328
+ } else if (t.isDone) {
242
329
  completed.push(t);
243
330
  } else if (!inspectingEndedSession && !runningTaskIds.has(String(t.id))) {
244
331
  paused.push(t);
245
332
  }
246
333
  }
247
- return { pausedTasks: paused, completedTasks: completed };
248
- }, [mergedTasksForBuckets, inspectingEndedSession, runningTaskIds]);
334
+ return {
335
+ plannedTasks: planned,
336
+ pausedTasks: paused,
337
+ completedTasks: completed,
338
+ };
339
+ }, [
340
+ mergedTasksForBuckets,
341
+ inspectingEndedSession,
342
+ runningTaskIds,
343
+ bucketNowMs,
344
+ ]);
249
345
  const pausedTasksDisplay = [...pausedTasks].reverse();
346
+ const plannedTasksDisplay = [...plannedTasks].reverse();
250
347
  const completedTasksDisplay = [...completedTasks].reverse();
348
+ const showPlannedTaskSection = plannedTasksDisplay.length > 0;
251
349
  const showPausedTaskSection =
252
350
  !inspectingEndedSession && pausedTasksDisplay.length > 0;
253
351
  const showCompletedTaskSection = completedTasksDisplay.length > 0;
254
352
  const showTaskListBuckets =
255
353
  (isInspecting || hasLiveSession) &&
256
- (showPausedTaskSection || showCompletedTaskSection);
257
- /** Pastille « total » seulement quand les deux colonnes sont visibles (évite le doublon avec un seul bloc). */
258
- const showSessionTaskCountBadge =
259
- !inspectingEndedSession &&
260
- showPausedTaskSection &&
261
- showCompletedTaskSection;
354
+ (showPlannedTaskSection ||
355
+ showPausedTaskSection ||
356
+ showCompletedTaskSection);
357
+ const bucketSectionsCount =
358
+ Number(showPlannedTaskSection) +
359
+ Number(showPausedTaskSection) +
360
+ Number(showCompletedTaskSection);
361
+ /** Pastille « total » lorsqu’au moins deux blocs de tâches sont visibles (évite le doublon avec un seul bloc). */
362
+ const showSessionTaskCountBadge = bucketSectionsCount >= 2;
363
+ const canAddHistoricalTaskToInspectingSession =
364
+ isInspecting && !!inspectingId;
365
+ const showTaskEntryControls =
366
+ !inspectingEndedSession || canAddHistoricalTaskToInspectingSession;
262
367
 
263
368
  const kronoFocusStatus = (live as LiveSessionShape | undefined)?.kronoFocus
264
369
  ?.status;
265
370
  const kronoFocusIsRunningOrPaused =
266
371
  kronoFocusStatus === "running" || kronoFocusStatus === "paused";
267
372
 
268
- const glCfg = payload.cfg as Record<string, unknown> | undefined;
269
- /** Jeton configuré : afficher l’import ; l’API refuse la recherche sans jeton valide (et message si connexion non testée). */
270
- const showRemoteIssueImport =
271
- glCfg?.gitlabTokenStored === true || glCfg?.gitlabTokenFromEnv === true;
272
-
273
373
  const [taskInput, setTaskInput] = useState("");
374
+ const [taskNoteInput, setTaskNoteInput] = useState("");
375
+ const [debouncedTemplateQuery, setDebouncedTemplateQuery] = useState("");
274
376
  const [taskEntryMode, setTaskEntryMode] = useState<TaskEntryMode>("realtime");
275
377
  const [pastStartLocal, setPastStartLocal] = useState("");
276
378
  const [pastEndLocal, setPastEndLocal] = useState("");
379
+ const [pastManualHours, setPastManualHours] = useState("0");
380
+ const [pastManualMinutes, setPastManualMinutes] = useState("0");
381
+ const [pastManualSeconds, setPastManualSeconds] = useState("0");
382
+ const pendingPastDurationMsRef = useRef<number | null>(null);
277
383
  const [startKronoFocusWithTask, setStartKronoFocusWithTask] = useState(false);
278
- const [issuePickerOpen, setIssuePickerOpen] = useState(false);
279
384
  const [alertMessage, setAlertMessage] = useState<string | null>(null);
280
385
  const [kronoFocusResetConfirmOpen, setKronoFocusResetConfirmOpen] =
281
386
  useState(false);
282
387
  const [deleteTaskConfirmId, setDeleteTaskConfirmId] = useState<string | null>(
283
388
  null,
284
389
  );
390
+ const [deletingTaskIds, setDeletingTaskIds] = useState<Set<string>>(
391
+ () => new Set(),
392
+ );
285
393
  const [concurrentStartModalOpen, setConcurrentStartModalOpen] =
286
394
  useState(false);
287
395
  const [rememberConcurrentStart, setRememberConcurrentStart] = useState(false);
396
+ const [taskListVisibleSessionId, setTaskListVisibleSessionId] = useState<
397
+ string | null
398
+ >(null);
399
+ const [taskListEnterSessionId, setTaskListEnterSessionId] = useState<
400
+ string | null
401
+ >(null);
288
402
  const rememberConcurrentRef = useRef(false);
289
403
  const pendingLiveStartRef = useRef<{
290
404
  name: string;
291
405
  tags: string[];
292
406
  project?: string;
407
+ personalProject?: boolean;
408
+ note: string;
293
409
  startKronoFocus: boolean;
410
+ saveAsTemplate: boolean;
294
411
  } | null>(null);
295
412
 
296
413
  useEffect(() => {
297
414
  rememberConcurrentRef.current = rememberConcurrentStart;
298
415
  }, [rememberConcurrentStart]);
299
416
 
417
+ useEffect(() => {
418
+ setConcurrentTaskStartConflictModalOpen(concurrentStartModalOpen);
419
+ return () => setConcurrentTaskStartConflictModalOpen(false);
420
+ }, [concurrentStartModalOpen]);
421
+
422
+ useEffect(() => {
423
+ const targetSessionId =
424
+ typeof sessionCurrent?.sessionId === "string" &&
425
+ sessionCurrent.sessionId.trim() !== ""
426
+ ? sessionCurrent.sessionId.trim()
427
+ : null;
428
+ if (!targetSessionId) {
429
+ setTaskListVisibleSessionId(null);
430
+ setTaskListEnterSessionId(null);
431
+ return;
432
+ }
433
+ setTaskListVisibleSessionId(targetSessionId);
434
+ setTaskListEnterSessionId(null);
435
+ let raf1 = 0;
436
+ let raf2 = 0;
437
+ raf1 = requestAnimationFrame(() => {
438
+ raf2 = requestAnimationFrame(() =>
439
+ setTaskListEnterSessionId(targetSessionId),
440
+ );
441
+ });
442
+ return () => {
443
+ cancelAnimationFrame(raf1);
444
+ cancelAnimationFrame(raf2);
445
+ };
446
+ }, [sessionCurrent?.sessionId]);
447
+
448
+ useEffect(() => {
449
+ if (!taskLauncherApplyDraftRef) {
450
+ return;
451
+ }
452
+ taskLauncherApplyDraftRef.current = (text: string) => {
453
+ setTaskInput(text);
454
+ globalThis.requestAnimationFrame(() => {
455
+ document.getElementById("kronosys-task-launcher-input")?.focus();
456
+ });
457
+ };
458
+ return () => {
459
+ taskLauncherApplyDraftRef.current = null;
460
+ };
461
+ }, [taskLauncherApplyDraftRef]);
462
+
463
+ useEffect(() => {
464
+ const t = globalThis.setTimeout(() => {
465
+ setDebouncedTemplateQuery(taskInput.trim().toLowerCase());
466
+ }, 220);
467
+ return () => globalThis.clearTimeout(t);
468
+ }, [taskInput]);
469
+
470
+ useEffect(() => {
471
+ const startMs = pastStartLocal.trim()
472
+ ? new Date(pastStartLocal).getTime()
473
+ : NaN;
474
+ const endMs = pastEndLocal.trim() ? new Date(pastEndLocal).getTime() : NaN;
475
+ const pending = pendingPastDurationMsRef.current;
476
+ if (
477
+ pending !== null &&
478
+ Number.isFinite(startMs) &&
479
+ Number.isFinite(endMs) &&
480
+ endMs > startMs
481
+ ) {
482
+ pendingPastDurationMsRef.current = null;
483
+ const spanMs = endMs - startMs;
484
+ const capped = Math.min(Math.max(0, pending), spanMs);
485
+ const parts = pastEntrySecondsToManualFieldParts(capped / 1000);
486
+ setPastManualHours(parts.h);
487
+ setPastManualMinutes(parts.m);
488
+ setPastManualSeconds(parts.s);
489
+ return;
490
+ }
491
+ if (
492
+ !Number.isFinite(startMs) ||
493
+ !Number.isFinite(endMs) ||
494
+ endMs <= startMs
495
+ ) {
496
+ return;
497
+ }
498
+ const spanSec = Math.floor((endMs - startMs) / 1000);
499
+ const parts = pastEntrySecondsToManualFieldParts(spanSec);
500
+ setPastManualHours(parts.h);
501
+ setPastManualMinutes(parts.m);
502
+ setPastManualSeconds(parts.s);
503
+ }, [pastStartLocal, pastEndLocal]);
504
+
300
505
  useEffect(() => {
301
506
  if (!showKronoFocusInTaskOps) {
302
507
  setStartKronoFocusWithTask(false);
@@ -332,6 +537,113 @@ export function TaskFocusPanel({
332
537
  const titleParsed = parseTaskWithAutoTags(taskInput);
333
538
  const mergedDraftTags = mergeTagsForDisplay(taskInput, []);
334
539
  const mergedDraftProject = titleParsed.project;
540
+ const mergedDraftPersonalProject = titleParsed.personalProject;
541
+ const taskTemplates = useMemo(
542
+ () =>
543
+ parseTaskTemplatesFromPayload(
544
+ (payload as Record<string, unknown>).taskTemplates,
545
+ ),
546
+ [payload],
547
+ );
548
+ const filteredTaskTemplates = useMemo(() => {
549
+ const q = debouncedTemplateQuery;
550
+ const rows =
551
+ q.length === 0
552
+ ? taskTemplates
553
+ : taskTemplates.filter((tpl) => {
554
+ const bag = [tpl.name, ...tpl.tags, tpl.project ?? ""]
555
+ .join(" ")
556
+ .toLowerCase();
557
+ return bag.includes(q);
558
+ });
559
+ return rows.slice(0, 20);
560
+ }, [debouncedTemplateQuery, taskTemplates]);
561
+ const taskTemplateSignatureSet = useMemo(() => {
562
+ const signatures = new Set<string>();
563
+ for (const tpl of taskTemplates) {
564
+ signatures.add(
565
+ buildTaskTemplateSignature({
566
+ name: tpl.name,
567
+ tags: tpl.tags,
568
+ project: tpl.project,
569
+ personalProject: tpl.personalProject === true,
570
+ }),
571
+ );
572
+ }
573
+ return signatures;
574
+ }, [taskTemplates]);
575
+ const isTaskAlreadyTemplate = useCallback(
576
+ (task: TaskRow) =>
577
+ taskTemplateSignatureSet.has(
578
+ buildTaskTemplateSignature({
579
+ name: task.name,
580
+ tags: mergeTagsForDisplay(task.name, task.tags ?? []),
581
+ project:
582
+ typeof task.project === "string" && task.project.trim()
583
+ ? task.project
584
+ : null,
585
+ personalProject: task.personalProject === true,
586
+ }),
587
+ ),
588
+ [taskTemplateSignatureSet],
589
+ );
590
+ const saveTaskAsTemplate = useCallback(
591
+ async (task: TaskRow) => {
592
+ const title = taskTitleForDisplay(task.name).trim();
593
+ const tagParts = (task.tags ?? []).map(
594
+ (tag) => `#${normalizeTagKey(tag)}`,
595
+ );
596
+ const project =
597
+ typeof task.project === "string" && task.project.trim()
598
+ ? formatProjectDisplay(task.project, {
599
+ personal: task.personalProject === true,
600
+ })
601
+ : "";
602
+ const draftString = [title, ...tagParts, project]
603
+ .filter(Boolean)
604
+ .join(" ")
605
+ .trim();
606
+ const draft = buildStartTaskFromDraft(draftString, [], undefined);
607
+ if (!draft.name.trim() && draft.tags.length === 0 && !draft.project) {
608
+ return;
609
+ }
610
+ await post({
611
+ type: "saveTaskTemplate",
612
+ name: draft.name,
613
+ tags: draft.tags,
614
+ ...(draft.project ? { project: draft.project } : {}),
615
+ ...(draft.project
616
+ ? { personalProject: draft.personalProject === true }
617
+ : {}),
618
+ });
619
+ pushToast(t.taskTemplateSavedToast);
620
+ },
621
+ [post, pushToast, t.taskTemplateSavedToast],
622
+ );
623
+
624
+ const saveDraftAsTemplate = useCallback(
625
+ async (draft: {
626
+ name: string;
627
+ tags: string[];
628
+ project?: string;
629
+ personalProject?: boolean;
630
+ }) => {
631
+ if (!draft.name.trim() && draft.tags.length === 0 && !draft.project) {
632
+ return;
633
+ }
634
+ await post({
635
+ type: "saveTaskTemplate",
636
+ name: draft.name,
637
+ tags: draft.tags,
638
+ ...(draft.project ? { project: draft.project } : {}),
639
+ ...(draft.project
640
+ ? { personalProject: draft.personalProject === true }
641
+ : {}),
642
+ });
643
+ pushToast(t.taskTemplateSavedToast);
644
+ },
645
+ [post, pushToast, t.taskTemplateSavedToast],
646
+ );
335
647
 
336
648
  const handleRemoveDraftTag = useCallback((tag: string) => {
337
649
  setTaskInput((prev) => {
@@ -342,20 +654,90 @@ export function TaskFocusPanel({
342
654
 
343
655
  const handleRemoveDraftProject = useCallback(() => {
344
656
  setTaskInput((prev) => {
345
- const regex = /(^|\s)@[\w.-]+(?=\s|$)/gi;
346
- return prev.replace(regex, " ").replace(/\s+/g, " ").trim();
657
+ let s = prev.replace(/(^|\s)@[^\s@#]+(?:#[^\s#]+)?(?=\s|$)/gi, " ");
658
+ s = s.replace(/(^|\s)![^\s!#]+(?:#[^\s#]+)?(?=\s|$)/gi, " ");
659
+ return s.replace(/\s+/g, " ").trim();
347
660
  });
348
661
  }, []);
349
662
 
350
- const confirmDeleteTask = useCallback((taskId: string) => {
351
- setDeleteTaskConfirmId(taskId);
352
- }, []);
663
+ const confirmDeleteTask = useCallback(
664
+ (taskId: string) => {
665
+ if (deletingTaskIds.has(taskId)) {
666
+ return;
667
+ }
668
+ setDeleteTaskConfirmId(taskId);
669
+ },
670
+ [deletingTaskIds],
671
+ );
672
+
673
+ useEffect(() => {
674
+ const visibleTaskIds = new Set(
675
+ mergedTasksForBuckets.map((task) => String(task.id)),
676
+ );
677
+ setDeletingTaskIds((prev) => {
678
+ if (prev.size === 0) {
679
+ return prev;
680
+ }
681
+ const next = new Set<string>();
682
+ for (const id of prev) {
683
+ if (visibleTaskIds.has(id)) {
684
+ next.add(id);
685
+ }
686
+ }
687
+ return next.size === prev.size ? prev : next;
688
+ });
689
+ }, [mergedTasksForBuckets]);
353
690
 
354
691
  const clearTaskEntryForm = useCallback(() => {
355
692
  setTaskInput("");
693
+ setTaskNoteInput("");
356
694
  setStartKronoFocusWithTask(false);
357
695
  }, []);
358
696
 
697
+ const duplicateTaskToDraft = useCallback(
698
+ (task: TaskRow) => {
699
+ const targetSessionId = inspectingId ?? live?.sessionId;
700
+ const title = taskTitleForDisplay(task.name).trim();
701
+ const tags = (task.tags ?? []).map((tag) => `#${normalizeTagKey(tag)}`);
702
+ const project =
703
+ typeof task.project === "string" && task.project.trim()
704
+ ? formatProjectDisplay(task.project, {
705
+ personal: task.personalProject === true,
706
+ })
707
+ : "";
708
+ const nextInput = [title, ...tags, project]
709
+ .filter(Boolean)
710
+ .join(" ")
711
+ .trim();
712
+ setTaskInput(nextInput);
713
+ setTaskNoteInput(typeof task.note === "string" ? task.note : "");
714
+ if (
715
+ typeof task.startTime === "string" &&
716
+ task.startTime.trim() &&
717
+ typeof task.endTime === "string" &&
718
+ task.endTime.trim() &&
719
+ targetSessionId
720
+ ) {
721
+ const start = new Date(task.startTime);
722
+ const end = new Date(task.endTime);
723
+ if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) {
724
+ const spanMs = Math.max(0, end.getTime() - start.getTime());
725
+ const durRaw =
726
+ typeof task.durationMs === "number" &&
727
+ Number.isFinite(task.durationMs)
728
+ ? task.durationMs
729
+ : spanMs;
730
+ pendingPastDurationMsRef.current =
731
+ spanMs > 0 ? Math.min(Math.max(0, durRaw), spanMs) : spanMs;
732
+ setTaskEntryMode("past");
733
+ setPastStartLocal(formatDatetimeLocalValue(start));
734
+ setPastEndLocal(formatDatetimeLocalValue(end));
735
+ }
736
+ }
737
+ },
738
+ [inspectingId, live?.sessionId],
739
+ );
740
+
359
741
  const resolveConcurrentAndStartLive = useCallback(
360
742
  async (mode: ConcurrentTaskStartPreference, persistPreference: boolean) => {
361
743
  const draft = pendingLiveStartRef.current;
@@ -380,7 +762,6 @@ export function TaskFocusPanel({
380
762
  await postKronosysAction({
381
763
  type: "finishTask",
382
764
  taskId: t.id,
383
- shouldCommit: false,
384
765
  });
385
766
  }
386
767
  }
@@ -390,15 +771,20 @@ export function TaskFocusPanel({
390
771
  name: draft.name,
391
772
  startKronoFocus: draft.startKronoFocus,
392
773
  tags: draft.tags,
774
+ note: draft.note,
393
775
  ...(draft.project ? { project: draft.project } : {}),
776
+ ...(draft.personalProject ? { personalProject: true } : {}),
394
777
  });
778
+ if (draft.saveAsTemplate) {
779
+ await saveDraftAsTemplate(draft);
780
+ }
395
781
  } catch (error) {
396
782
  pushToast(
397
783
  typeof error === "string"
398
784
  ? error
399
785
  : error instanceof Error
400
- ? error.message
401
- : "Failed to resolve concurrent tasks",
786
+ ? error.message
787
+ : "Failed to resolve concurrent tasks",
402
788
  );
403
789
  pendingLiveStartRef.current = null;
404
790
  setConcurrentStartModalOpen(false);
@@ -410,15 +796,20 @@ export function TaskFocusPanel({
410
796
  setRememberConcurrentStart(false);
411
797
  clearTaskEntryForm();
412
798
  },
413
- [live, post, clearTaskEntryForm, pushToast],
799
+ [live, post, clearTaskEntryForm, pushToast, saveDraftAsTemplate],
414
800
  );
415
801
 
416
802
  const submitStartTask = useCallback(async () => {
417
- const hasDraft = taskInput.trim().length > 0;
803
+ const draftControl = parseDraftTemplateMarker(taskInput);
804
+ const hasDraft = draftControl.normalizedDraft.length > 0;
418
805
  if (!hasDraft) {
419
806
  return;
420
807
  }
421
- const payloadStart = buildStartTaskFromDraft(taskInput, [], undefined);
808
+ const payloadStart = buildStartTaskFromDraft(
809
+ draftControl.normalizedDraft,
810
+ [],
811
+ undefined,
812
+ );
422
813
  if (
423
814
  !payloadStart.name.trim() &&
424
815
  payloadStart.tags.length === 0 &&
@@ -430,7 +821,10 @@ export function TaskFocusPanel({
430
821
  name: payloadStart.name,
431
822
  tags: payloadStart.tags,
432
823
  ...(payloadStart.project ? { project: payloadStart.project } : {}),
824
+ ...(payloadStart.personalProject ? { personalProject: true } : {}),
433
825
  startKronoFocus: showKronoFocusInTaskOps && startKronoFocusWithTask,
826
+ saveAsTemplate: draftControl.saveAsTemplate,
827
+ note: taskNoteInput,
434
828
  };
435
829
  if (!isInspecting && !hasLiveSession) {
436
830
  pushToast(t.taskStartAutoSessionToast);
@@ -440,14 +834,23 @@ export function TaskFocusPanel({
440
834
  name: body.name,
441
835
  startKronoFocus: body.startKronoFocus,
442
836
  tags: body.tags,
837
+ note: body.note,
443
838
  ...(body.project ? { project: body.project } : {}),
839
+ ...(body.personalProject ? { personalProject: true } : {}),
444
840
  });
841
+ if (body.saveAsTemplate) {
842
+ await saveDraftAsTemplate(body);
843
+ }
445
844
  clearTaskEntryForm();
446
845
  return;
447
846
  }
448
847
  if (!isInspecting && hasLiveSession && live) {
449
848
  const running = runningTasksFromSession(live);
450
849
  if (running.length > 0) {
850
+ if (isPlannedBoundaryConflictModalOpen()) {
851
+ pushToast(t.taskStartBlockedByPlannedBoundaryConflict);
852
+ return;
853
+ }
451
854
  const pref = readConcurrentTaskStartPreference();
452
855
  pendingLiveStartRef.current = body;
453
856
  if (pref === "pause") {
@@ -472,8 +875,13 @@ export function TaskFocusPanel({
472
875
  name: body.name,
473
876
  startKronoFocus: body.startKronoFocus,
474
877
  tags: body.tags,
878
+ note: body.note,
475
879
  ...(body.project ? { project: body.project } : {}),
880
+ ...(body.personalProject ? { personalProject: true } : {}),
476
881
  });
882
+ if (body.saveAsTemplate) {
883
+ await saveDraftAsTemplate(body);
884
+ }
477
885
  clearTaskEntryForm();
478
886
  }, [
479
887
  clearTaskEntryForm,
@@ -483,9 +891,11 @@ export function TaskFocusPanel({
483
891
  post,
484
892
  pushToast,
485
893
  resolveConcurrentAndStartLive,
894
+ saveDraftAsTemplate,
486
895
  showKronoFocusInTaskOps,
487
896
  startKronoFocusWithTask,
488
897
  t.taskStartAutoSessionToast,
898
+ t.taskStartBlockedByPlannedBoundaryConflict,
489
899
  taskInput,
490
900
  ]);
491
901
 
@@ -518,7 +928,16 @@ export function TaskFocusPanel({
518
928
  setAlertMessage(t.archiveTaskDatetimeRangeInvalid);
519
929
  return;
520
930
  }
521
- const durationMs = Math.round(endMs - startMs);
931
+ const spanMs = Math.round(endMs - startMs);
932
+ const durationMs = parsePastManualDurationMs(
933
+ pastManualHours,
934
+ pastManualMinutes,
935
+ pastManualSeconds,
936
+ );
937
+ if (durationMs <= 0 || durationMs > spanMs) {
938
+ setAlertMessage(t.archiveTaskManualDurationInvalid);
939
+ return;
940
+ }
522
941
  const payloadStart = buildStartTaskFromDraft(nameRaw, [], undefined);
523
942
  if (
524
943
  !payloadStart.name.trim() &&
@@ -532,22 +951,32 @@ export function TaskFocusPanel({
532
951
  sessionId: retroTargetSessionId,
533
952
  name: payloadStart.name,
534
953
  tags: payloadStart.tags,
954
+ note: taskNoteInput,
535
955
  durationMs,
536
956
  startTime: new Date(startMs).toISOString(),
537
957
  endTime: new Date(endMs).toISOString(),
538
958
  ...(payloadStart.project ? { project: payloadStart.project } : {}),
959
+ ...(payloadStart.personalProject ? { personalProject: true } : {}),
539
960
  });
540
961
  setTaskInput("");
962
+ setTaskNoteInput("");
541
963
  setPastStartLocal("");
542
964
  setPastEndLocal("");
965
+ setPastManualHours("0");
966
+ setPastManualMinutes("0");
967
+ setPastManualSeconds("0");
543
968
  }, [
544
969
  pastEndLocal,
970
+ pastManualHours,
971
+ pastManualMinutes,
972
+ pastManualSeconds,
545
973
  pastStartLocal,
546
974
  post,
547
975
  retroTargetSessionId,
548
976
  t.archiveTaskDatetimeRangeInvalid,
549
- t.archiveTaskDatetimeRangeInvalid,
977
+ t.archiveTaskManualDurationInvalid,
550
978
  taskInput,
979
+ taskNoteInput,
551
980
  ]);
552
981
 
553
982
  const pastRangeValid =
@@ -557,7 +986,22 @@ export function TaskFocusPanel({
557
986
  Number.isFinite(new Date(pastEndLocal).getTime()) &&
558
987
  new Date(pastEndLocal).getTime() > new Date(pastStartLocal).getTime();
559
988
 
560
- const canSubmitPast = !!taskInput.trim() && pastRangeValid;
989
+ const pastManualDurationMs = parsePastManualDurationMs(
990
+ pastManualHours,
991
+ pastManualMinutes,
992
+ pastManualSeconds,
993
+ );
994
+ const spanMsPast = pastRangeValid
995
+ ? Math.round(
996
+ new Date(pastEndLocal).getTime() - new Date(pastStartLocal).getTime(),
997
+ )
998
+ : 0;
999
+ const pastManualDurationValid =
1000
+ pastRangeValid &&
1001
+ pastManualDurationMs > 0 &&
1002
+ pastManualDurationMs <= spanMsPast;
1003
+
1004
+ const canSubmitPast = !!taskInput.trim() && pastManualDurationValid;
561
1005
 
562
1006
  /** Début + fin sur une seule ligne (scroll horizontal si besoin), contenu centré dans la zone. */
563
1007
  const pastDateTimeRow = (
@@ -583,17 +1027,63 @@ export function TaskFocusPanel({
583
1027
  onChange={setPastEndLocal}
584
1028
  aria-label={t.archiveTaskEndLabel}
585
1029
  lang={lang}
1030
+ defaultTimeMode="next-half-hour"
586
1031
  t={t}
587
1032
  />
588
1033
  </label>
589
1034
  </div>
590
1035
  );
591
1036
 
1037
+ const pastTimerDurationRow = (
1038
+ <div className="mx-auto w-max max-w-full space-y-1">
1039
+ <div className="text-center text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
1040
+ {t.archiveTaskTimerDurationLabel}
1041
+ </div>
1042
+ <div className="flex flex-wrap items-end justify-center gap-x-2 gap-y-1">
1043
+ <label className="flex w-[3.25rem] shrink-0 flex-col text-xs">
1044
+ {t.taskTimingAdjustHours}
1045
+ <input
1046
+ type="number"
1047
+ min={0}
1048
+ value={pastManualHours}
1049
+ onChange={(e) => setPastManualHours(e.target.value)}
1050
+ className="mt-1 w-full min-w-0 rounded border border-zinc-300 px-1.5 py-1 text-center tabular-nums dark:border-zinc-600 dark:bg-zinc-900"
1051
+ />
1052
+ </label>
1053
+ <label className="flex w-[3.25rem] shrink-0 flex-col text-xs">
1054
+ {t.taskTimingAdjustMinutes}
1055
+ <input
1056
+ type="number"
1057
+ min={0}
1058
+ max={59}
1059
+ value={pastManualMinutes}
1060
+ onChange={(e) => setPastManualMinutes(e.target.value)}
1061
+ className="mt-1 w-full min-w-0 rounded border border-zinc-300 px-1.5 py-1 text-center tabular-nums dark:border-zinc-600 dark:bg-zinc-900"
1062
+ />
1063
+ </label>
1064
+ <label className="flex w-[3.25rem] shrink-0 flex-col text-xs">
1065
+ {t.taskTimingAdjustSeconds}
1066
+ <input
1067
+ type="number"
1068
+ min={0}
1069
+ max={59}
1070
+ value={pastManualSeconds}
1071
+ onChange={(e) => setPastManualSeconds(e.target.value)}
1072
+ className="mt-1 w-full min-w-0 rounded border border-zinc-300 px-1.5 py-1 text-center tabular-nums dark:border-zinc-600 dark:bg-zinc-900"
1073
+ />
1074
+ </label>
1075
+ </div>
1076
+ </div>
1077
+ );
1078
+
592
1079
  const pastDatetimeRowInspecting = (
593
- <div className="flex w-full min-w-0 justify-center">
1080
+ <div className="flex w-full min-w-0 flex-col items-center gap-3">
594
1081
  <div className="kronosys-past-datetime-blink-twice min-w-0 max-w-full">
595
1082
  {pastDateTimeRow}
596
1083
  </div>
1084
+ <div className="kronosys-past-datetime-blink-twice w-full max-w-full">
1085
+ {pastTimerDurationRow}
1086
+ </div>
597
1087
  </div>
598
1088
  );
599
1089
 
@@ -620,58 +1110,16 @@ export function TaskFocusPanel({
620
1110
  await post({ type: "setPaused", paused: false });
621
1111
  };
622
1112
 
1113
+ const resumeLiveSessionWall = async () => {
1114
+ await post({ type: "setPaused", paused: false });
1115
+ };
1116
+
623
1117
  const historyBannerTitle = isPauseState
624
1118
  ? t.pausedNote
625
1119
  : t.viewingHistoryBannerTitle;
626
1120
  const viewingSessionLabel =
627
1121
  sessionCurrent?.sessionName?.trim() || sessionCurrent?.sessionId || "—";
628
1122
 
629
- const fetchGitlabIssues = useCallback(
630
- async (query: string) => {
631
- const glCfg = payload.cfg as Record<string, unknown> | undefined;
632
- const base =
633
- typeof glCfg?.gitlabApiBaseUrl === "string"
634
- ? glCfg.gitlabApiBaseUrl.trim()
635
- : "";
636
- try {
637
- const res = await postKronosysAction(
638
- {
639
- type: "fetchRemoteIssues",
640
- lang,
641
- search: query,
642
- gitlabApiBaseUrl: base,
643
- },
644
- { signal: AbortSignal.timeout(TRACE_ISSUE_SEARCH_CLIENT_TIMEOUT_MS) },
645
- );
646
- const err = res.result?.remoteIssuesError;
647
- if (err) {
648
- return {
649
- issues: [] as RemoteIssue[],
650
- error: typeof err === "string" ? err : String(err),
651
- };
652
- }
653
- const list = res.result?.remoteIssues;
654
- return { issues: Array.isArray(list) ? (list as RemoteIssue[]) : [] };
655
- } catch (e) {
656
- const aborted =
657
- e instanceof Error &&
658
- (e.name === "AbortError" || e.name === "TimeoutError");
659
- if (aborted) {
660
- return {
661
- issues: [] as RemoteIssue[],
662
- error: t.issuePickerDashboardRequestTimeout,
663
- };
664
- }
665
- throw e;
666
- }
667
- },
668
- [lang, payload.cfg, t],
669
- );
670
-
671
- const onOpenGitlabIssuePicker = useCallback(() => {
672
- setIssuePickerOpen(true);
673
- }, []);
674
-
675
1123
  const showTaskModeToggle = !!retroTargetSessionId;
676
1124
  const effectiveTaskEntryMode: TaskEntryMode = showTaskModeToggle
677
1125
  ? taskEntryMode
@@ -709,310 +1157,302 @@ export function TaskFocusPanel({
709
1157
  </section>
710
1158
  )}
711
1159
 
712
- {isInspecting &&
713
- (inspectSessionEndReasonLine || canEditInspectSessionEndReason) ? (
714
- <div className="mb-4 rounded-lg border border-zinc-200 bg-zinc-50/90 px-4 py-2.5 text-left text-sm text-zinc-700 dark:border-zinc-700/80 dark:bg-zinc-900/40 dark:text-zinc-300">
715
- <div className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
716
- {t.selectedSessionEndReasonTitle}
717
- </div>
718
- {inspectSessionEndReasonLine ? (
719
- <p className="mt-1 whitespace-pre-wrap leading-snug">
720
- {inspectSessionEndReasonLine}
721
- </p>
722
- ) : null}
723
- {canEditInspectSessionEndReason ? (
724
- <div
725
- className={
726
- inspectSessionEndReasonLine
727
- ? "mt-3 border-t border-zinc-200 pt-3 dark:border-zinc-600/80"
728
- : "mt-2"
729
- }
730
- >
731
- <SessionEndReasonEditor
732
- t={t}
733
- radioGroupName="kronosys-session-end-reason-edit-tasks"
734
- sessionId={sessionEndReasonEditSid}
735
- initialKind={viewingSession?.sessionEndReasonKind}
736
- initialNote={viewingSession?.sessionEndReasonNote}
737
- post={post}
738
- />
1160
+ {!isInspecting &&
1161
+ hasLiveSession &&
1162
+ isPauseState &&
1163
+ !globalPauseContextActive ? (
1164
+ <section
1165
+ className="relative z-[41] mb-4 flex flex-col gap-3 rounded-lg border border-amber-800/55 bg-[#120d0a] px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
1166
+ aria-label={t.liveSessionWallPausedBannerAria}
1167
+ >
1168
+ <div className="flex min-w-0 items-start gap-2">
1169
+ <Pause
1170
+ className="mt-0.5 shrink-0 text-amber-400"
1171
+ size={18}
1172
+ aria-hidden
1173
+ />
1174
+ <div className="min-w-0">
1175
+ <strong className="block text-sm text-amber-200">
1176
+ {t.liveSessionWallPausedTitle}
1177
+ </strong>
1178
+ <p className="mt-1 text-xs font-normal leading-snug text-amber-300/85">
1179
+ {t.liveSessionWallPausedDetail}
1180
+ </p>
739
1181
  </div>
740
- ) : null}
741
- </div>
1182
+ </div>
1183
+ <button
1184
+ type="button"
1185
+ className="shrink-0 rounded-lg border border-amber-800/60 bg-amber-950/50 px-3 py-1.5 text-sm font-medium text-amber-200 hover:bg-amber-950/70"
1186
+ onClick={() => void resumeLiveSessionWall()}
1187
+ >
1188
+ {t.liveSessionResumeWallBtn}
1189
+ </button>
1190
+ </section>
742
1191
  ) : null}
743
1192
 
744
1193
  <div className="space-y-6">
745
- <>
746
- {!inspectingEndedSession ? (
747
- <>
748
- <div className="mb-5 grid min-h-8 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2">
749
- <span className="shrink-0 text-[0.7rem] font-semibold uppercase tracking-wide text-zinc-500">
750
- {t.taskTrackerTitle}
751
- </span>
752
- <div className="flex min-w-0 justify-center justify-self-stretch px-0.5 sm:px-1">
753
- {!isInspecting &&
754
- showTaskModeToggle &&
755
- effectiveTaskEntryMode === "past" ? (
1194
+ {showTaskEntryControls ? (
1195
+ <>
1196
+ <div className="mb-5 grid min-h-8 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2">
1197
+ <span className="shrink-0 text-[0.7rem] font-semibold uppercase tracking-wide text-zinc-500">
1198
+ {t.taskTrackerTitle}
1199
+ </span>
1200
+ <div className="flex min-w-0 justify-center justify-self-stretch px-0.5 sm:px-1">
1201
+ {!isInspecting &&
1202
+ showTaskModeToggle &&
1203
+ effectiveTaskEntryMode === "past" ? (
1204
+ <div className="flex min-w-0 max-w-full flex-col items-center gap-2">
756
1205
  <div className="kronosys-past-datetime-blink-twice min-w-0 max-w-full">
757
1206
  {pastDateTimeRow}
758
1207
  </div>
759
- ) : null}
1208
+ <div className="kronosys-past-datetime-blink-twice w-full max-w-full">
1209
+ {pastTimerDurationRow}
1210
+ </div>
1211
+ </div>
1212
+ ) : null}
1213
+ </div>
1214
+ <div className="flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
1215
+ {!isInspecting && trackingActive ? (
1216
+ <div
1217
+ className="flex items-center gap-2 text-sm font-bold text-emerald-400"
1218
+ aria-live="polite"
1219
+ >
1220
+ <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
1221
+ <span className="whitespace-nowrap">
1222
+ {lang === "fr" ? "SUIVI" : "TRACKING"}
1223
+ {runningTasks.length > 1 ? (
1224
+ <span className="ml-1.5 font-mono text-xs font-semibold tabular-nums opacity-90">
1225
+ ({runningTasks.length})
1226
+ </span>
1227
+ ) : null}
1228
+ </span>
1229
+ </div>
1230
+ ) : null}
1231
+ {!isInspecting &&
1232
+ showTaskModeToggle &&
1233
+ effectiveTaskEntryMode === "past" ? (
1234
+ <InlineMetricHelpTrigger
1235
+ ariaLabel={t.archiveAddTaskIntroHelpAria}
1236
+ body={t.archiveAddTaskIntro}
1237
+ panelClassName="w-[min(calc(100vw-2rem),22rem)]"
1238
+ />
1239
+ ) : null}
1240
+ </div>
1241
+ </div>
1242
+
1243
+ {isInspecting && inspectingId ? (
1244
+ <div className="min-w-0 space-y-4">
1245
+ <div className="flex justify-end">
1246
+ <InlineMetricHelpTrigger
1247
+ ariaLabel={t.archiveAddTaskIntroHelpAria}
1248
+ body={t.archiveAddTaskIntro}
1249
+ panelClassName="w-[min(calc(100vw-2rem),22rem)]"
1250
+ />
760
1251
  </div>
761
- <div className="flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
762
- {!isInspecting && trackingActive ? (
763
- <div
764
- className="flex items-center gap-2 text-sm font-bold text-emerald-400"
765
- aria-live="polite"
1252
+ {pastDatetimeRowInspecting}
1253
+ {mergedDraftProject?.trim() ? (
1254
+ <div className="flex min-w-0 justify-start">
1255
+ <span
1256
+ className={`${
1257
+ mergedDraftPersonalProject === true
1258
+ ? PROJECT_CHIP_APPLIED_PERSONAL_CLASS
1259
+ : PROJECT_CHIP_APPLIED_CLASS
1260
+ } max-w-full truncate`}
1261
+ title={formatProjectDisplay(mergedDraftProject, {
1262
+ personal: mergedDraftPersonalProject === true,
1263
+ })}
766
1264
  >
767
- <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
768
- <span className="whitespace-nowrap">
769
- {lang === "fr" ? "SUIVI" : "TRACKING"}
770
- {runningTasks.length > 1 ? (
771
- <span className="ml-1.5 font-mono text-xs font-semibold tabular-nums opacity-90">
772
- ({runningTasks.length})
773
- </span>
774
- ) : null}
1265
+ {formatProjectDisplay(mergedDraftProject, {
1266
+ personal: mergedDraftPersonalProject === true,
1267
+ })}
1268
+ </span>
1269
+ </div>
1270
+ ) : null}
1271
+ <div className="flex min-h-10 min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
1272
+ <input
1273
+ id="kronosys-task-launcher-input"
1274
+ className={`${TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS} min-w-0 text-center sm:min-w-[12rem]`}
1275
+ placeholder={t.taskPlaceholderPast}
1276
+ value={taskInput}
1277
+ list="kronosys-task-templates"
1278
+ onChange={(e) => setTaskInput(e.target.value)}
1279
+ onKeyDown={(e) => {
1280
+ if (e.key === "Enter" && canSubmitPast) {
1281
+ e.preventDefault();
1282
+ void submitAddHistoricalTask();
1283
+ }
1284
+ }}
1285
+ aria-label={t.taskPlaceholderPast}
1286
+ />
1287
+ <div className="flex h-10 shrink-0 items-center gap-1">
1288
+ <button
1289
+ type="button"
1290
+ className={tbVioletIcon}
1291
+ disabled={!canSubmitPast}
1292
+ title={t.archiveAddTaskBtn}
1293
+ aria-label={t.archiveAddTaskBtn}
1294
+ onClick={() => void submitAddHistoricalTask()}
1295
+ >
1296
+ <Play
1297
+ size={22}
1298
+ className="ml-0.5"
1299
+ fill="currentColor"
1300
+ aria-hidden
1301
+ />
1302
+ </button>
1303
+ </div>
1304
+ </div>
1305
+ <textarea
1306
+ className="w-full rounded-lg border border-zinc-300 bg-white/90 px-3 py-2 text-sm text-zinc-800 outline-none transition focus:border-violet-500 dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-100"
1307
+ placeholder={t.taskNotePlaceholder}
1308
+ value={taskNoteInput}
1309
+ onChange={(e) => setTaskNoteInput(e.target.value)}
1310
+ rows={3}
1311
+ aria-label={t.taskNoteLabel}
1312
+ />
1313
+ {taskFormTagSection}
1314
+ </div>
1315
+ ) : (
1316
+ <div className="min-w-0 space-y-4">
1317
+ {mergedDraftProject?.trim() ? (
1318
+ <div className="flex min-w-0 justify-start">
1319
+ <span
1320
+ className={`${
1321
+ mergedDraftPersonalProject === true
1322
+ ? PROJECT_CHIP_APPLIED_PERSONAL_CLASS
1323
+ : PROJECT_CHIP_APPLIED_CLASS
1324
+ } inline-flex items-center gap-1 max-w-full truncate pr-1`}
1325
+ title={formatProjectDisplay(mergedDraftProject, {
1326
+ personal: mergedDraftPersonalProject === true,
1327
+ })}
1328
+ >
1329
+ <span className="truncate">
1330
+ {formatProjectDisplay(mergedDraftProject, {
1331
+ personal: mergedDraftPersonalProject === true,
1332
+ })}
775
1333
  </span>
776
- </div>
777
- ) : null}
778
- {!isInspecting &&
779
- showTaskModeToggle &&
780
- effectiveTaskEntryMode === "past" ? (
781
- <InlineMetricHelpTrigger
782
- ariaLabel={t.archiveAddTaskIntroHelpAria}
783
- body={t.archiveAddTaskIntro}
784
- panelClassName="w-[min(calc(100vw-2rem),22rem)]"
785
- />
786
- ) : null}
787
- {!isInspecting && showRemoteIssueImport ? (
788
- <div className="flex flex-wrap items-center gap-2">
789
1334
  <button
790
1335
  type="button"
791
- className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-300 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-800 disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
792
- title={t.importGitIssue}
793
- aria-label={t.importGitIssue}
794
- onClick={() => onOpenGitlabIssuePicker()}
1336
+ className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full hover:bg-black/10 dark:hover:bg-white/10"
1337
+ onClick={handleRemoveDraftProject}
795
1338
  >
796
- <Ticket size={15} />
1339
+ <span className="text-[0.65rem] font-bold leading-none">
1340
+ ×
1341
+ </span>
797
1342
  </button>
798
- </div>
799
- ) : null}
800
- </div>
801
- </div>
802
-
803
- {isInspecting && inspectingId ? (
804
- <div className="min-w-0 space-y-4">
805
- <div className="flex justify-end">
806
- <InlineMetricHelpTrigger
807
- ariaLabel={t.archiveAddTaskIntroHelpAria}
808
- body={t.archiveAddTaskIntro}
809
- panelClassName="w-[min(calc(100vw-2rem),22rem)]"
810
- />
1343
+ </span>
811
1344
  </div>
812
- {pastDatetimeRowInspecting}
813
- {mergedDraftProject?.trim() ? (
814
- <div className="flex min-w-0 justify-start">
815
- <span
816
- className={`${PROJECT_CHIP_APPLIED_CLASS} max-w-full truncate`}
817
- title={formatProjectDisplay(mergedDraftProject)}
818
- >
819
- {formatProjectDisplay(mergedDraftProject)}
820
- </span>
821
- </div>
822
- ) : null}
823
- <div className="flex min-h-10 min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
824
- <input
825
- className={`${TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS} min-w-0 text-center sm:min-w-[12rem]`}
826
- placeholder={t.taskPlaceholderPast}
827
- value={taskInput}
828
- onChange={(e) => setTaskInput(e.target.value)}
829
- onKeyDown={(e) => {
830
- if (e.key === "Enter" && canSubmitPast) {
831
- e.preventDefault();
832
- void submitAddHistoricalTask();
833
- }
834
- }}
835
- aria-label={t.taskPlaceholderPast}
836
- />
837
- <div className="flex h-10 shrink-0 items-center gap-1">
838
- {showRemoteIssueImport ? (
839
- <button
840
- type="button"
841
- className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-300 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-800 disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
842
- title={t.importGitIssue}
843
- aria-label={t.importGitIssue}
844
- onClick={() => onOpenGitlabIssuePicker()}
845
- >
846
- <Ticket size={15} />
847
- </button>
848
- ) : null}
1345
+ ) : null}
1346
+ <div className="flex min-h-10 min-w-0 items-center gap-2">
1347
+ {showTaskModeToggle ? (
1348
+ <div
1349
+ role="group"
1350
+ aria-label={t.taskEntryModeGroupAria}
1351
+ className="flex h-10 shrink-0 items-center gap-1"
1352
+ >
849
1353
  <button
850
1354
  type="button"
851
- className={tbVioletIcon}
852
- disabled={!canSubmitPast}
853
- title={t.archiveAddTaskBtn}
854
- aria-label={t.archiveAddTaskBtn}
855
- onClick={() => void submitAddHistoricalTask()}
1355
+ className={
1356
+ effectiveTaskEntryMode === "realtime"
1357
+ ? tbVioletToggleOn
1358
+ : tbVioletToggleOff
1359
+ }
1360
+ aria-pressed={effectiveTaskEntryMode === "realtime"}
1361
+ title={t.taskEntryModeRealtime}
1362
+ aria-label={t.taskEntryModeRealtime}
1363
+ onClick={() => setTaskEntryMode("realtime")}
856
1364
  >
857
- <Play
858
- size={22}
859
- className="ml-0.5"
860
- fill="currentColor"
861
- aria-hidden
862
- />
1365
+ <Zap size={18} strokeWidth={2.25} aria-hidden />
863
1366
  </button>
864
- </div>
865
- </div>
866
- {taskFormTagSection}
867
- </div>
868
- ) : (
869
- <div className="min-w-0 space-y-4">
870
- {mergedDraftProject?.trim() ? (
871
- <div className="flex min-w-0 justify-start">
872
- <span
873
- className={`${PROJECT_CHIP_APPLIED_CLASS} inline-flex items-center gap-1 max-w-full truncate pr-1`}
874
- title={formatProjectDisplay(mergedDraftProject)}
1367
+ <button
1368
+ type="button"
1369
+ className={
1370
+ effectiveTaskEntryMode === "past"
1371
+ ? tbVioletToggleOn
1372
+ : tbVioletToggleOff
1373
+ }
1374
+ aria-pressed={effectiveTaskEntryMode === "past"}
1375
+ title={t.taskEntryModePast}
1376
+ aria-label={t.taskEntryModePast}
1377
+ onClick={() => setTaskEntryMode("past")}
875
1378
  >
876
- <span className="truncate">
877
- {formatProjectDisplay(mergedDraftProject)}
878
- </span>
879
- <button
880
- type="button"
881
- className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full hover:bg-black/10 dark:hover:bg-white/10"
882
- onClick={handleRemoveDraftProject}
883
- >
884
- <span className="text-[0.65rem] font-bold leading-none">
885
- ×
886
- </span>
887
- </button>
888
- </span>
1379
+ <History size={18} strokeWidth={2} aria-hidden />
1380
+ </button>
889
1381
  </div>
890
1382
  ) : null}
891
- <div className="flex min-h-10 min-w-0 items-center gap-2">
892
- {showTaskModeToggle ? (
893
- <div
894
- role="group"
895
- aria-label={t.taskEntryModeGroupAria}
896
- className="flex h-10 shrink-0 items-center gap-1"
897
- >
898
- <button
899
- type="button"
900
- className={
901
- effectiveTaskEntryMode === "realtime"
902
- ? tbVioletToggleOn
903
- : tbVioletToggleOff
904
- }
905
- aria-pressed={effectiveTaskEntryMode === "realtime"}
906
- title={t.taskEntryModeRealtime}
907
- aria-label={t.taskEntryModeRealtime}
908
- onClick={() => setTaskEntryMode("realtime")}
909
- >
910
- <Zap size={18} strokeWidth={2.25} aria-hidden />
911
- </button>
912
- <button
913
- type="button"
914
- className={
915
- effectiveTaskEntryMode === "past"
916
- ? tbVioletToggleOn
917
- : tbVioletToggleOff
918
- }
919
- aria-pressed={effectiveTaskEntryMode === "past"}
920
- title={t.taskEntryModePast}
921
- aria-label={t.taskEntryModePast}
922
- onClick={() => setTaskEntryMode("past")}
923
- >
924
- <History size={18} strokeWidth={2} aria-hidden />
925
- </button>
926
- </div>
927
- ) : null}
928
- <input
929
- className={`${TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS} min-w-0 flex-1 text-center`}
930
- placeholder={
931
- effectiveTaskEntryMode === "past"
932
- ? t.taskPlaceholderPast
933
- : t.taskPlaceholder
1383
+ <input
1384
+ id="kronosys-task-launcher-input"
1385
+ className={`${TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS} min-w-0 flex-1 text-center`}
1386
+ placeholder={
1387
+ effectiveTaskEntryMode === "past"
1388
+ ? t.taskPlaceholderPast
1389
+ : t.taskPlaceholder
1390
+ }
1391
+ value={taskInput}
1392
+ list="kronosys-task-templates"
1393
+ onChange={(e) => setTaskInput(e.target.value)}
1394
+ onKeyDown={(e) => {
1395
+ if (e.key !== "Enter") {
1396
+ return;
934
1397
  }
935
- value={taskInput}
936
- onChange={(e) => setTaskInput(e.target.value)}
937
- onKeyDown={(e) => {
938
- if (e.key !== "Enter") {
939
- return;
940
- }
941
- if (
942
- effectiveTaskEntryMode === "realtime" &&
943
- taskInput.trim()
944
- ) {
945
- e.preventDefault();
946
- void submitStartTask();
947
- return;
948
- }
949
- if (
950
- effectiveTaskEntryMode === "past" &&
951
- canSubmitPast &&
952
- retroTargetSessionId
953
- ) {
954
- e.preventDefault();
955
- void submitAddHistoricalTask();
956
- }
957
- }}
958
- aria-label={
959
- effectiveTaskEntryMode === "past"
960
- ? t.taskPlaceholderPast
961
- : t.taskPlaceholder
1398
+ if (
1399
+ effectiveTaskEntryMode === "realtime" &&
1400
+ taskInput.trim()
1401
+ ) {
1402
+ e.preventDefault();
1403
+ void submitStartTask();
1404
+ return;
962
1405
  }
963
- />
964
- <div className="flex h-10 shrink-0 items-center gap-1">
965
- {effectiveTaskEntryMode === "realtime" ? (
966
- <>
967
- {showKronoFocusInTaskOps ? (
968
- <button
969
- type="button"
970
- role="switch"
971
- aria-checked={
972
- startKronoFocusWithTask ? "true" : "false"
973
- }
974
- aria-label={
975
- startKronoFocusWithTask
976
- ? t.startTaskWithKronoFocusToggleAriaOn
977
- : t.startTaskWithKronoFocusToggleAriaOff
978
- }
979
- title={t.startTaskWithKronoFocus}
980
- onClick={() =>
981
- setStartKronoFocusWithTask((v) => !v)
982
- }
983
- className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg transition-colors [&_svg]:shrink-0 ${
984
- startKronoFocusWithTask
985
- ? "border-2 border-violet-500/65 bg-violet-500/12 text-violet-900 shadow-none hover:border-violet-500/80 hover:bg-violet-500/18 dark:border-violet-400/55 dark:bg-violet-600/22 dark:text-violet-100 dark:hover:border-violet-400/70 dark:hover:bg-violet-600/30"
986
- : "border border-zinc-300 bg-white/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-400"
987
- } hover:bg-zinc-100/90 dark:hover:bg-zinc-800/40`}
988
- >
989
- <Timer size={20} strokeWidth={2} aria-hidden />
990
- </button>
991
- ) : null}
1406
+ if (
1407
+ effectiveTaskEntryMode === "past" &&
1408
+ canSubmitPast &&
1409
+ retroTargetSessionId
1410
+ ) {
1411
+ e.preventDefault();
1412
+ void submitAddHistoricalTask();
1413
+ }
1414
+ }}
1415
+ aria-label={
1416
+ effectiveTaskEntryMode === "past"
1417
+ ? t.taskPlaceholderPast
1418
+ : t.taskPlaceholder
1419
+ }
1420
+ />
1421
+ <div className="flex h-10 shrink-0 items-center gap-1">
1422
+ {effectiveTaskEntryMode === "realtime" ? (
1423
+ <>
1424
+ {showKronoFocusInTaskOps ? (
992
1425
  <button
993
1426
  type="button"
994
- className={tbVioletIcon}
995
- disabled={!taskInput.trim()}
996
- title={t.startTaskBtn}
997
- aria-label={t.startTaskBtn}
998
- onClick={() => void submitStartTask()}
1427
+ role="switch"
1428
+ aria-checked={
1429
+ startKronoFocusWithTask ? "true" : "false"
1430
+ }
1431
+ aria-label={
1432
+ startKronoFocusWithTask
1433
+ ? t.startTaskWithKronoFocusToggleAriaOn
1434
+ : t.startTaskWithKronoFocusToggleAriaOff
1435
+ }
1436
+ title={t.startTaskWithKronoFocus}
1437
+ onClick={() =>
1438
+ setStartKronoFocusWithTask((v) => !v)
1439
+ }
1440
+ className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg transition-colors [&_svg]:shrink-0 ${
1441
+ startKronoFocusWithTask
1442
+ ? "border-2 border-violet-500/65 bg-violet-500/12 text-violet-900 shadow-none hover:border-violet-500/80 hover:bg-violet-500/18 dark:border-violet-400/55 dark:bg-violet-600/22 dark:text-violet-100 dark:hover:border-violet-400/70 dark:hover:bg-violet-600/30"
1443
+ : "border border-zinc-300 bg-white/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-400"
1444
+ } hover:bg-zinc-100/90 dark:hover:bg-zinc-800/40`}
999
1445
  >
1000
- <Play
1001
- size={22}
1002
- className="ml-0.5"
1003
- fill="currentColor"
1004
- aria-hidden
1005
- />
1446
+ <Timer size={20} strokeWidth={2} aria-hidden />
1006
1447
  </button>
1007
- </>
1008
- ) : (
1448
+ ) : null}
1009
1449
  <button
1010
1450
  type="button"
1011
1451
  className={tbVioletIcon}
1012
- disabled={!canSubmitPast || !retroTargetSessionId}
1013
- title={t.archiveAddTaskBtn}
1014
- aria-label={t.archiveAddTaskBtn}
1015
- onClick={() => void submitAddHistoricalTask()}
1452
+ disabled={!taskInput.trim()}
1453
+ title={t.startTaskBtn}
1454
+ aria-label={t.startTaskBtn}
1455
+ onClick={() => void submitStartTask()}
1016
1456
  >
1017
1457
  <Play
1018
1458
  size={22}
@@ -1021,30 +1461,78 @@ export function TaskFocusPanel({
1021
1461
  aria-hidden
1022
1462
  />
1023
1463
  </button>
1024
- )}
1025
- </div>
1464
+ </>
1465
+ ) : (
1466
+ <button
1467
+ type="button"
1468
+ className={tbVioletIcon}
1469
+ disabled={!canSubmitPast || !retroTargetSessionId}
1470
+ title={t.archiveAddTaskBtn}
1471
+ aria-label={t.archiveAddTaskBtn}
1472
+ onClick={() => void submitAddHistoricalTask()}
1473
+ >
1474
+ <Play
1475
+ size={22}
1476
+ className="ml-0.5"
1477
+ fill="currentColor"
1478
+ aria-hidden
1479
+ />
1480
+ </button>
1481
+ )}
1026
1482
  </div>
1027
- {taskFormTagSection}
1028
1483
  </div>
1029
- )}
1030
- </>
1031
- ) : null}
1032
-
1033
- {!isInspecting && runningTasks.length > 0 ? (
1034
- <div className="min-w-0 pt-2">
1035
- <div className="mb-3 flex items-baseline justify-between gap-3">
1036
- <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
1037
- {t.tasksRunningHeading}
1038
- </h3>
1039
- <span className="tabular-nums text-[0.7rem] font-medium text-emerald-600 dark:text-emerald-400/90">
1040
- {runningTasks.length}
1041
- </span>
1484
+ <textarea
1485
+ className="w-full rounded-lg border border-zinc-300 bg-white/90 px-3 py-2 text-sm text-zinc-800 outline-none transition focus:border-violet-500 dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-100"
1486
+ placeholder={t.taskNotePlaceholder}
1487
+ value={taskNoteInput}
1488
+ onChange={(e) => setTaskNoteInput(e.target.value)}
1489
+ rows={3}
1490
+ aria-label={t.taskNoteLabel}
1491
+ />
1492
+ {taskFormTagSection}
1042
1493
  </div>
1043
- <div className="divide-y divide-zinc-200/80 dark:divide-zinc-700/80">
1044
- {runningTasks.map((task) => (
1494
+ )}
1495
+ <datalist id="kronosys-task-templates">
1496
+ {filteredTaskTemplates.map((tpl) => (
1497
+ <option key={tpl.id} value={formatTaskTemplateDraftLine(tpl)}>
1498
+ {formatTaskTemplateDatalistLabel(
1499
+ tpl,
1500
+ t.taskTemplateDatalistPrefix,
1501
+ )}
1502
+ </option>
1503
+ ))}
1504
+ </datalist>
1505
+ </>
1506
+ ) : null}
1507
+
1508
+ {!isInspecting && runningTasks.length > 0 ? (
1509
+ <div
1510
+ className={`min-w-0 overflow-hidden pt-2 transition-[opacity,transform,max-height] duration-300 ease-out motion-reduce:transition-none ${
1511
+ taskListVisibleSessionId !== null &&
1512
+ taskListEnterSessionId === taskListVisibleSessionId
1513
+ ? "max-h-[1000rem] translate-y-0 opacity-100"
1514
+ : "max-h-0 translate-y-1 opacity-0"
1515
+ }`}
1516
+ >
1517
+ <div className="mb-3 flex items-baseline justify-between gap-3">
1518
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
1519
+ {t.tasksRunningHeading}
1520
+ </h3>
1521
+ <span className="tabular-nums text-[0.7rem] font-medium text-emerald-600 dark:text-emerald-400/90">
1522
+ {runningTasks.length}
1523
+ </span>
1524
+ </div>
1525
+ <div className="space-y-3">
1526
+ {runningTasks.map((task) => (
1527
+ <div
1528
+ key={task.id}
1529
+ className={`transition-all duration-150 ease-out motion-reduce:transition-none ${
1530
+ deletingTaskIds.has(String(task.id))
1531
+ ? "pointer-events-none translate-y-1 scale-[0.985] opacity-0"
1532
+ : "translate-y-0 scale-100 opacity-100"
1533
+ }`}
1534
+ >
1045
1535
  <TaskSessionLiveCard
1046
- key={task.id}
1047
- variant="plain"
1048
1536
  task={task}
1049
1537
  lang={lang}
1050
1538
  displayTimeZone={displayTimeZone}
@@ -1061,6 +1549,9 @@ export function TaskFocusPanel({
1061
1549
  startKronoFocusFromTask={() =>
1062
1550
  void startKronoFocusFromTask()
1063
1551
  }
1552
+ onDuplicateTask={duplicateTaskToDraft}
1553
+ onSaveAsTemplate={(task) => void saveTaskAsTemplate(task)}
1554
+ isAlreadyTemplate={isTaskAlreadyTemplate(task)}
1064
1555
  pausePlayMode="pause"
1065
1556
  onPausePlay={() =>
1066
1557
  void post({
@@ -1073,15 +1564,22 @@ export function TaskFocusPanel({
1073
1564
  allowTaskStartTimeEdit={allowTaskStartTimeEdit}
1074
1565
  allowTaskEndTimeEdit={allowTaskEndTimeEdit}
1075
1566
  />
1076
- ))}
1077
- </div>
1567
+ </div>
1568
+ ))}
1078
1569
  </div>
1079
- ) : null}
1080
- </>
1570
+ </div>
1571
+ ) : null}
1081
1572
  </div>
1082
1573
 
1083
1574
  {showTaskListBuckets ? (
1084
- <div className="mt-8 space-y-10">
1575
+ <div
1576
+ className={`mt-8 space-y-10 overflow-hidden transition-[opacity,transform,max-height] duration-300 ease-out motion-reduce:transition-none ${
1577
+ taskListVisibleSessionId !== null &&
1578
+ taskListEnterSessionId === taskListVisibleSessionId
1579
+ ? "max-h-[1000rem] translate-y-0 opacity-100"
1580
+ : "max-h-0 translate-y-1 opacity-0"
1581
+ }`}
1582
+ >
1085
1583
  {showSessionTaskCountBadge ? (
1086
1584
  <div className="flex justify-end">
1087
1585
  <span className="rounded-full border border-zinc-600 px-2 py-0.5 text-xs text-zinc-400">
@@ -1090,6 +1588,86 @@ export function TaskFocusPanel({
1090
1588
  </div>
1091
1589
  ) : null}
1092
1590
 
1591
+ {showPlannedTaskSection ? (
1592
+ <div className="space-y-2">
1593
+ <div className="flex items-center justify-between gap-2">
1594
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-sky-700 dark:text-sky-300/90">
1595
+ {t.tasksPlannedHeading}
1596
+ </h4>
1597
+ <span className="rounded-full border border-sky-600/50 bg-sky-50/80 px-2 py-0.5 text-[0.65rem] text-sky-900 dark:border-sky-500/45 dark:bg-sky-950/40 dark:text-sky-200">
1598
+ {plannedTasksDisplay.length}
1599
+ </span>
1600
+ </div>
1601
+ <div className="space-y-3">
1602
+ {plannedTasksDisplay.map((task) => {
1603
+ const stackRow = runningTasksRaw.find(
1604
+ (r) => String(r.id) === String(task.id),
1605
+ );
1606
+ const pauseMode: "pause" | "resume" | null = task.isDone
1607
+ ? null
1608
+ : stackRow && !stackRow.manualTaskTimerPaused
1609
+ ? "pause"
1610
+ : "resume";
1611
+ return (
1612
+ <div
1613
+ key={task.id}
1614
+ className={`transition-all duration-150 ease-out motion-reduce:transition-none ${
1615
+ deletingTaskIds.has(String(task.id))
1616
+ ? "pointer-events-none translate-y-1 scale-[0.985] opacity-0"
1617
+ : "translate-y-0 scale-100 opacity-100"
1618
+ }`}
1619
+ >
1620
+ <TaskSessionLiveCard
1621
+ task={task}
1622
+ lang={lang}
1623
+ displayTimeZone={displayTimeZone}
1624
+ use24HourClock={use24HourClock}
1625
+ isInspecting={isInspecting}
1626
+ inspectingId={inspectingId}
1627
+ knownTags={knownTags}
1628
+ knownProjects={knownProjects}
1629
+ post={post}
1630
+ t={t}
1631
+ confirmDeleteTask={confirmDeleteTask}
1632
+ kronoFocusIsRunningOrPaused={
1633
+ kronoFocusIsRunningOrPaused
1634
+ }
1635
+ showKronoFocusTaskActions={showKronoFocusInTaskOps}
1636
+ startKronoFocusFromTask={() =>
1637
+ void startKronoFocusFromTask()
1638
+ }
1639
+ onDuplicateTask={duplicateTaskToDraft}
1640
+ onSaveAsTemplate={(task) =>
1641
+ void saveTaskAsTemplate(task)
1642
+ }
1643
+ isAlreadyTemplate={isTaskAlreadyTemplate(task)}
1644
+ pausePlayMode={pauseMode}
1645
+ temporalPlanned
1646
+ onPausePlay={() => {
1647
+ if (pauseMode === "pause") {
1648
+ void post({
1649
+ type: "setTaskTimerPaused",
1650
+ taskId: task.id,
1651
+ paused: true,
1652
+ });
1653
+ } else if (pauseMode === "resume") {
1654
+ void post({
1655
+ type: "resumePausedTask",
1656
+ taskId: task.id,
1657
+ });
1658
+ }
1659
+ }}
1660
+ anchorId={`kronosys-task-${task.id}`}
1661
+ allowTaskStartTimeEdit={allowTaskStartTimeEdit}
1662
+ allowTaskEndTimeEdit={allowTaskEndTimeEdit}
1663
+ />
1664
+ </div>
1665
+ );
1666
+ })}
1667
+ </div>
1668
+ </div>
1669
+ ) : null}
1670
+
1093
1671
  {showPausedTaskSection ? (
1094
1672
  <div className="space-y-2">
1095
1673
  <div className="flex items-center justify-between gap-2">
@@ -1102,32 +1680,43 @@ export function TaskFocusPanel({
1102
1680
  </div>
1103
1681
  <div className="space-y-3">
1104
1682
  {pausedTasksDisplay.map((task) => (
1105
- <TaskSessionLiveCard
1683
+ <div
1106
1684
  key={task.id}
1107
- task={task}
1108
- lang={lang}
1109
- displayTimeZone={displayTimeZone}
1110
- use24HourClock={use24HourClock}
1111
- isInspecting={isInspecting}
1112
- inspectingId={inspectingId}
1113
- knownTags={knownTags}
1114
- knownProjects={knownProjects}
1115
- post={post}
1116
- t={t}
1117
- confirmDeleteTask={confirmDeleteTask}
1118
- kronoFocusIsRunningOrPaused={kronoFocusIsRunningOrPaused}
1119
- showKronoFocusTaskActions={showKronoFocusInTaskOps}
1120
- startKronoFocusFromTask={() =>
1121
- void startKronoFocusFromTask()
1122
- }
1123
- pausePlayMode="resume"
1124
- onPausePlay={() =>
1125
- void post({ type: "resumePausedTask", taskId: task.id })
1126
- }
1127
- anchorId={`kronosys-task-${task.id}`}
1128
- allowTaskStartTimeEdit={allowTaskStartTimeEdit}
1129
- allowTaskEndTimeEdit={allowTaskEndTimeEdit}
1130
- />
1685
+ className={`transition-all duration-150 ease-out motion-reduce:transition-none ${
1686
+ deletingTaskIds.has(String(task.id))
1687
+ ? "pointer-events-none translate-y-1 scale-[0.985] opacity-0"
1688
+ : "translate-y-0 scale-100 opacity-100"
1689
+ }`}
1690
+ >
1691
+ <TaskSessionLiveCard
1692
+ task={task}
1693
+ lang={lang}
1694
+ displayTimeZone={displayTimeZone}
1695
+ use24HourClock={use24HourClock}
1696
+ isInspecting={isInspecting}
1697
+ inspectingId={inspectingId}
1698
+ knownTags={knownTags}
1699
+ knownProjects={knownProjects}
1700
+ post={post}
1701
+ t={t}
1702
+ confirmDeleteTask={confirmDeleteTask}
1703
+ kronoFocusIsRunningOrPaused={kronoFocusIsRunningOrPaused}
1704
+ showKronoFocusTaskActions={showKronoFocusInTaskOps}
1705
+ startKronoFocusFromTask={() =>
1706
+ void startKronoFocusFromTask()
1707
+ }
1708
+ onDuplicateTask={duplicateTaskToDraft}
1709
+ onSaveAsTemplate={(task) => void saveTaskAsTemplate(task)}
1710
+ isAlreadyTemplate={isTaskAlreadyTemplate(task)}
1711
+ pausePlayMode="resume"
1712
+ onPausePlay={() =>
1713
+ void post({ type: "resumePausedTask", taskId: task.id })
1714
+ }
1715
+ anchorId={`kronosys-task-${task.id}`}
1716
+ allowTaskStartTimeEdit={allowTaskStartTimeEdit}
1717
+ allowTaskEndTimeEdit={allowTaskEndTimeEdit}
1718
+ />
1719
+ </div>
1131
1720
  ))}
1132
1721
  </div>
1133
1722
  </div>
@@ -1145,30 +1734,41 @@ export function TaskFocusPanel({
1145
1734
  </div>
1146
1735
  <div className="space-y-3">
1147
1736
  {completedTasksDisplay.map((task) => (
1148
- <TaskSessionLiveCard
1737
+ <div
1149
1738
  key={task.id}
1150
- task={task}
1151
- lang={lang}
1152
- displayTimeZone={displayTimeZone}
1153
- use24HourClock={use24HourClock}
1154
- isInspecting={isInspecting}
1155
- inspectingId={inspectingId}
1156
- knownTags={knownTags}
1157
- knownProjects={knownProjects}
1158
- post={post}
1159
- t={t}
1160
- confirmDeleteTask={confirmDeleteTask}
1161
- kronoFocusIsRunningOrPaused={kronoFocusIsRunningOrPaused}
1162
- showKronoFocusTaskActions={showKronoFocusInTaskOps}
1163
- startKronoFocusFromTask={() =>
1164
- void startKronoFocusFromTask()
1165
- }
1166
- pausePlayMode={null}
1167
- onPausePlay={() => {}}
1168
- anchorId={`kronosys-task-${task.id}`}
1169
- allowTaskStartTimeEdit={allowTaskStartTimeEdit}
1170
- allowTaskEndTimeEdit={allowTaskEndTimeEdit}
1171
- />
1739
+ className={`transition-all duration-150 ease-out motion-reduce:transition-none ${
1740
+ deletingTaskIds.has(String(task.id))
1741
+ ? "pointer-events-none translate-y-1 scale-[0.985] opacity-0"
1742
+ : "translate-y-0 scale-100 opacity-100"
1743
+ }`}
1744
+ >
1745
+ <TaskSessionLiveCard
1746
+ task={task}
1747
+ lang={lang}
1748
+ displayTimeZone={displayTimeZone}
1749
+ use24HourClock={use24HourClock}
1750
+ isInspecting={isInspecting}
1751
+ inspectingId={inspectingId}
1752
+ knownTags={knownTags}
1753
+ knownProjects={knownProjects}
1754
+ post={post}
1755
+ t={t}
1756
+ confirmDeleteTask={confirmDeleteTask}
1757
+ kronoFocusIsRunningOrPaused={kronoFocusIsRunningOrPaused}
1758
+ showKronoFocusTaskActions={showKronoFocusInTaskOps}
1759
+ startKronoFocusFromTask={() =>
1760
+ void startKronoFocusFromTask()
1761
+ }
1762
+ onDuplicateTask={duplicateTaskToDraft}
1763
+ onSaveAsTemplate={(task) => void saveTaskAsTemplate(task)}
1764
+ isAlreadyTemplate={isTaskAlreadyTemplate(task)}
1765
+ pausePlayMode={null}
1766
+ onPausePlay={() => {}}
1767
+ anchorId={`kronosys-task-${task.id}`}
1768
+ allowTaskStartTimeEdit={allowTaskStartTimeEdit}
1769
+ allowTaskEndTimeEdit={allowTaskEndTimeEdit}
1770
+ />
1771
+ </div>
1172
1772
  ))}
1173
1773
  </div>
1174
1774
  </div>
@@ -1176,19 +1776,6 @@ export function TaskFocusPanel({
1176
1776
  </div>
1177
1777
  ) : null}
1178
1778
 
1179
- {issuePickerOpen ? (
1180
- <IssuePickerModal
1181
- t={t}
1182
- fetchIssues={fetchGitlabIssues}
1183
- onClose={() => setIssuePickerOpen(false)}
1184
- onSelect={(issue) => {
1185
- const title = String(issue.title ?? "").trimEnd();
1186
- const suffix = `#${issue.number}`;
1187
- setTaskInput(title ? `${title}${suffix}` : suffix);
1188
- setIssuePickerOpen(false);
1189
- }}
1190
- />
1191
- ) : null}
1192
1779
  <DashboardAlertModal
1193
1780
  open={alertMessage !== null}
1194
1781
  message={alertMessage ?? ""}
@@ -1214,11 +1801,27 @@ export function TaskFocusPanel({
1214
1801
  const id = deleteTaskConfirmId;
1215
1802
  setDeleteTaskConfirmId(null);
1216
1803
  if (id) {
1217
- void post({
1218
- type: "deleteTask",
1219
- taskId: id,
1220
- sessionId: inspectingId,
1804
+ setDeletingTaskIds((prev) => {
1805
+ const next = new Set(prev);
1806
+ next.add(String(id));
1807
+ return next;
1221
1808
  });
1809
+ globalThis.setTimeout(() => {
1810
+ void post({
1811
+ type: "deleteTask",
1812
+ taskId: id,
1813
+ sessionId: inspectingId,
1814
+ }).finally(() => {
1815
+ setDeletingTaskIds((prev) => {
1816
+ if (!prev.has(String(id))) {
1817
+ return prev;
1818
+ }
1819
+ const next = new Set(prev);
1820
+ next.delete(String(id));
1821
+ return next;
1822
+ });
1823
+ });
1824
+ }, TASK_DELETE_ANIM_MS);
1222
1825
  }
1223
1826
  }}
1224
1827
  />