@nightkatana/kronosys-app 1.0.0-beta.0

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 (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,1261 @@
1
+ "use client";
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";
6
+ import { postKronosysAction } from "@/lib/kronosysApi";
7
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
8
+ import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
9
+ import {
10
+ buildStartTaskFromDraft,
11
+ formatProjectDisplay,
12
+ mergeTagsForDisplay,
13
+ parseTaskWithAutoTags,
14
+ removeProjectFromDraft,
15
+ removeSavedTagFromDraft,
16
+ } from "@/lib/taskParsing";
17
+ import { TagPills } from "./TagPills";
18
+ import {
19
+ PROJECT_CHIP_APPLIED_CLASS,
20
+ TASK_FOCUS_LAUNCHER_INPUT_ROW_CLASS,
21
+ } from "./taskFieldStyles";
22
+ import { KronosysDatetimePopoverField } from "./KronosysDatetimePopoverField";
23
+ import { TaskSessionLiveCard } from "./TaskSessionLiveCard";
24
+ import { IssuePickerModal, type RemoteIssue } from "./IssuePickerModal";
25
+ import {
26
+ DashboardAlertModal,
27
+ DashboardConfirmModal,
28
+ DashboardTriActionModal,
29
+ } from "./DashboardSimpleModal";
30
+ import {
31
+ tbVioletIcon,
32
+ tbVioletToggleOff,
33
+ tbVioletToggleOn,
34
+ } from "@/lib/translucentButtonClasses";
35
+ import { InlineMetricHelpTrigger } from "./InlineMetricHelpTrigger";
36
+ import {
37
+ readConcurrentTaskStartPreference,
38
+ writeConcurrentTaskStartPreference,
39
+ type ConcurrentTaskStartPreference,
40
+ } from "@/lib/concurrentTaskStartPreference";
41
+ import { mergeLiveSessionIntoHistory } from "@/lib/sessionListMerge";
42
+ import { formatSessionEndReasonLine } from "@/lib/sessionEndReason";
43
+ import type { SessionListEntry } from "@/components/dashboard/SessionListPanel";
44
+ import { SessionEndReasonEditor } from "@/components/dashboard/SessionEndReasonEditor";
45
+ import { useDashboardToast } from "@/components/dashboard/DashboardToastProvider";
46
+
47
+ type TaskEntryMode = "realtime" | "past";
48
+
49
+ type TaskRow = {
50
+ id: string;
51
+ name: string;
52
+ durationMs: number;
53
+ isDone: boolean;
54
+ startTime?: string;
55
+ endTime?: string;
56
+ tags?: string[];
57
+ project?: string | null;
58
+ subtasks?: Array<{
59
+ id: string;
60
+ title: string;
61
+ done: boolean;
62
+ durationMs?: number;
63
+ }>;
64
+ manualTaskTimerPaused?: boolean;
65
+ activeSubtaskTimerId?: string;
66
+ };
67
+
68
+ type SessionShape = {
69
+ sessionId?: string;
70
+ sessionName?: string;
71
+ isPaused?: boolean;
72
+ /** Présent et non vide lorsque la session a été clôturée (instantané d’historique). */
73
+ endAt?: string | null;
74
+ sessionEndReasonKind?: string;
75
+ sessionEndReasonNote?: string;
76
+ activeTasks?: TaskRow[];
77
+ activeTask?: TaskRow | null;
78
+ tasks?: TaskRow[];
79
+ };
80
+
81
+ function runningTasksFromSession(
82
+ session: SessionShape | null | undefined,
83
+ ): TaskRow[] {
84
+ if (!session) {
85
+ return [];
86
+ }
87
+ const raw =
88
+ Array.isArray(session.activeTasks) && session.activeTasks.length > 0
89
+ ? session.activeTasks
90
+ : session.activeTask
91
+ ? [session.activeTask]
92
+ : [];
93
+ return raw.filter((t) => t && !t.isDone && !t.manualTaskTimerPaused);
94
+ }
95
+
96
+ type LiveSessionShape = SessionShape & {
97
+ kronoFocus?: { status?: "idle" | "running" | "paused" };
98
+ };
99
+
100
+ export function TaskFocusPanel({
101
+ payload,
102
+ lang,
103
+ t,
104
+ refresh,
105
+ urlFocusedSessionId,
106
+ liveSessionId,
107
+ displayTimeZone,
108
+ use24HourClock,
109
+ onResumeActiveSession,
110
+ showKronoFocusInTaskOps = true,
111
+ allowTaskStartTimeEdit = true,
112
+ allowTaskEndTimeEdit = true,
113
+ }: {
114
+ payload: KronosysUpdatePayload;
115
+ lang: Lang;
116
+ t: DashboardStrings;
117
+ refresh: () => Promise<boolean | void>;
118
+ /** Onglet `?session=` : id affiché (live ou archive), prioritaire sur `inspectingSessionId`. */
119
+ urlFocusedSessionId?: string;
120
+ liveSessionId?: string;
121
+ displayTimeZone: string;
122
+ use24HourClock: boolean;
123
+ /** Retour à la session active (réinitialise l’URL si `?session=`). */
124
+ onResumeActiveSession?: () => Promise<void>;
125
+ /** Option `dashboardShowKronoFocusInTaskOps` : boutons Krono Focus dans la colonne tâches. */
126
+ showKronoFocusInTaskOps?: boolean;
127
+ /** Option `dashboardAllowTaskStartTimeEdit` : correction de l'heure de début des tâches. */
128
+ allowTaskStartTimeEdit?: boolean;
129
+ /** Option `dashboardAllowTaskEndTimeEdit` : correction de l'heure de fin des tâches terminées. */
130
+ allowTaskEndTimeEdit?: boolean;
131
+ }) {
132
+ const live = payload.current as SessionShape | undefined;
133
+ const history = useMemo(
134
+ () =>
135
+ mergeLiveSessionIntoHistory(
136
+ (payload.history || []) as SessionListEntry[],
137
+ live,
138
+ ) as SessionShape[],
139
+ [payload.history, live],
140
+ );
141
+ const historyArchivedList = (payload.historyArchived || []) as SessionShape[];
142
+ const payloadInspect =
143
+ (payload.inspectingSessionId as string | null | undefined) ?? null;
144
+ const liveId = liveSessionId ?? live?.sessionId;
145
+ const hasLiveSession = typeof liveId === "string" && liveId.length > 0;
146
+
147
+ let archiveColumnId: string | null = null;
148
+ if (urlFocusedSessionId !== undefined) {
149
+ if (urlFocusedSessionId && urlFocusedSessionId !== liveId) {
150
+ archiveColumnId = urlFocusedSessionId;
151
+ }
152
+ } else if (payloadInspect) {
153
+ archiveColumnId = payloadInspect;
154
+ }
155
+
156
+ const knownTags = (payload.knownTags || []) as string[];
157
+ const knownProjects = (payload.knownProjects || []) as string[];
158
+ const viewingSession = archiveColumnId
159
+ ? (history.find((s) => s.sessionId === archiveColumnId) ??
160
+ historyArchivedList.find((s) => s.sessionId === archiveColumnId) ??
161
+ null)
162
+ : null;
163
+ const sessionCurrent = viewingSession ?? live;
164
+ const isInspecting = !!viewingSession;
165
+ const inspectingId = archiveColumnId;
166
+ const isPauseState = !!live?.isPaused;
167
+ const inspectSessionEndReasonLine = viewingSession
168
+ ? formatSessionEndReasonLine(
169
+ t,
170
+ viewingSession.sessionEndReasonKind,
171
+ viewingSession.sessionEndReasonNote,
172
+ )
173
+ : null;
174
+ /** Session passée **terminée** : on n’affiche que les entrées marquées terminées (pas de blocs en cours / en pause). */
175
+ const inspectingEndedSession =
176
+ isInspecting &&
177
+ typeof viewingSession?.endAt === "string" &&
178
+ 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 !== "";
192
+
193
+ const taskSessionForBuckets = viewingSession ?? live;
194
+ /** Tâches live au minuteur (hors archive). */
195
+ const runningTasks = !isInspecting ? runningTasksFromSession(live) : [];
196
+ const runningTaskIds = useMemo(
197
+ () => new Set(runningTasks.map((t) => String(t.id))),
198
+ [runningTasks],
199
+ );
200
+ /**
201
+ * `tasks` + pile `activeTasks` / `activeTask` (dédoublonné par id, la pile l’emporte).
202
+ * Sinon une tâche uniquement sur la pile — après pause concurrente — n’apparaît ni « en cours » ni « en pause ».
203
+ */
204
+ const mergedTasksForBuckets = useMemo(() => {
205
+ const sess = taskSessionForBuckets;
206
+ if (!sess) {
207
+ return [] as TaskRow[];
208
+ }
209
+ const map = new Map<string, TaskRow>();
210
+ const base = Array.isArray(sess.tasks) ? (sess.tasks as TaskRow[]) : [];
211
+ for (const t of base) {
212
+ if (t?.id) {
213
+ map.set(String(t.id), t);
214
+ }
215
+ }
216
+ const stack: TaskRow[] =
217
+ Array.isArray(sess.activeTasks) && sess.activeTasks.length > 0
218
+ ? [...(sess.activeTasks as TaskRow[])]
219
+ : sess.activeTask
220
+ ? [sess.activeTask as TaskRow]
221
+ : [];
222
+ for (const t of stack) {
223
+ if (t?.id) {
224
+ map.set(String(t.id), t);
225
+ }
226
+ }
227
+ return [...map.values()];
228
+ }, [taskSessionForBuckets]);
229
+
230
+ /**
231
+ * Bandeau TRACKING : uniquement si la collecte n’est pas en pause session
232
+ * (sinon le minuteur de suivi n’est pas actif, mais l’interpolation locale faisait défiler l’horloge).
233
+ */
234
+ const trackingActive =
235
+ !isInspecting && runningTasks.length > 0 && !isPauseState;
236
+
237
+ const { pausedTasks, completedTasks } = useMemo(() => {
238
+ const paused: TaskRow[] = [];
239
+ const completed: TaskRow[] = [];
240
+ for (const t of mergedTasksForBuckets) {
241
+ if (t.isDone) {
242
+ completed.push(t);
243
+ } else if (!inspectingEndedSession && !runningTaskIds.has(String(t.id))) {
244
+ paused.push(t);
245
+ }
246
+ }
247
+ return { pausedTasks: paused, completedTasks: completed };
248
+ }, [mergedTasksForBuckets, inspectingEndedSession, runningTaskIds]);
249
+ const pausedTasksDisplay = [...pausedTasks].reverse();
250
+ const completedTasksDisplay = [...completedTasks].reverse();
251
+ const showPausedTaskSection =
252
+ !inspectingEndedSession && pausedTasksDisplay.length > 0;
253
+ const showCompletedTaskSection = completedTasksDisplay.length > 0;
254
+ const showTaskListBuckets =
255
+ (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;
262
+
263
+ const kronoFocusStatus = (live as LiveSessionShape | undefined)?.kronoFocus
264
+ ?.status;
265
+ const kronoFocusIsRunningOrPaused =
266
+ kronoFocusStatus === "running" || kronoFocusStatus === "paused";
267
+
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
+ const [taskInput, setTaskInput] = useState("");
274
+ const [taskEntryMode, setTaskEntryMode] = useState<TaskEntryMode>("realtime");
275
+ const [pastStartLocal, setPastStartLocal] = useState("");
276
+ const [pastEndLocal, setPastEndLocal] = useState("");
277
+ const [startKronoFocusWithTask, setStartKronoFocusWithTask] = useState(false);
278
+ const [issuePickerOpen, setIssuePickerOpen] = useState(false);
279
+ const [alertMessage, setAlertMessage] = useState<string | null>(null);
280
+ const [kronoFocusResetConfirmOpen, setKronoFocusResetConfirmOpen] =
281
+ useState(false);
282
+ const [deleteTaskConfirmId, setDeleteTaskConfirmId] = useState<string | null>(
283
+ null,
284
+ );
285
+ const [concurrentStartModalOpen, setConcurrentStartModalOpen] =
286
+ useState(false);
287
+ const [rememberConcurrentStart, setRememberConcurrentStart] = useState(false);
288
+ const rememberConcurrentRef = useRef(false);
289
+ const pendingLiveStartRef = useRef<{
290
+ name: string;
291
+ tags: string[];
292
+ project?: string;
293
+ startKronoFocus: boolean;
294
+ } | null>(null);
295
+
296
+ useEffect(() => {
297
+ rememberConcurrentRef.current = rememberConcurrentStart;
298
+ }, [rememberConcurrentStart]);
299
+
300
+ useEffect(() => {
301
+ if (!showKronoFocusInTaskOps) {
302
+ setStartKronoFocusWithTask(false);
303
+ }
304
+ }, [showKronoFocusInTaskOps]);
305
+
306
+ const post = useCallback(
307
+ async (body: Record<string, unknown>) => {
308
+ await postKronosysAction(body);
309
+ await refresh();
310
+ },
311
+ [refresh],
312
+ );
313
+
314
+ const { pushToast } = useDashboardToast();
315
+
316
+ const startKronoFocusFromTask = useCallback(async () => {
317
+ const timer = (live as LiveSessionShape | undefined)?.kronoFocus;
318
+ const st = timer?.status;
319
+ if (st === "running" || st === "paused") {
320
+ setKronoFocusResetConfirmOpen(true);
321
+ return;
322
+ }
323
+ await post({ type: "startKronoFocus", linkToActiveTask: true });
324
+ }, [live, post]);
325
+
326
+ const executeKronoFocusResetAndStart = useCallback(async () => {
327
+ setKronoFocusResetConfirmOpen(false);
328
+ await post({ type: "resetKronoFocus" });
329
+ await post({ type: "startKronoFocus", linkToActiveTask: true });
330
+ }, [post]);
331
+
332
+ const titleParsed = parseTaskWithAutoTags(taskInput);
333
+ const mergedDraftTags = mergeTagsForDisplay(taskInput, []);
334
+ const mergedDraftProject = titleParsed.project;
335
+
336
+ const handleRemoveDraftTag = useCallback((tag: string) => {
337
+ setTaskInput((prev) => {
338
+ const regex = new RegExp(`(^|\\s)#${tag}(?=\\s|$)`, "gi");
339
+ return prev.replace(regex, " ").replace(/\s+/g, " ").trim();
340
+ });
341
+ }, []);
342
+
343
+ const handleRemoveDraftProject = useCallback(() => {
344
+ setTaskInput((prev) => {
345
+ const regex = /(^|\s)@[\w.-]+(?=\s|$)/gi;
346
+ return prev.replace(regex, " ").replace(/\s+/g, " ").trim();
347
+ });
348
+ }, []);
349
+
350
+ const confirmDeleteTask = useCallback((taskId: string) => {
351
+ setDeleteTaskConfirmId(taskId);
352
+ }, []);
353
+
354
+ const clearTaskEntryForm = useCallback(() => {
355
+ setTaskInput("");
356
+ setStartKronoFocusWithTask(false);
357
+ }, []);
358
+
359
+ const resolveConcurrentAndStartLive = useCallback(
360
+ async (mode: ConcurrentTaskStartPreference, persistPreference: boolean) => {
361
+ const draft = pendingLiveStartRef.current;
362
+ if (!draft || !live) {
363
+ setConcurrentStartModalOpen(false);
364
+ return;
365
+ }
366
+ if (persistPreference) {
367
+ writeConcurrentTaskStartPreference(mode);
368
+ }
369
+ const running = runningTasksFromSession(live);
370
+ try {
371
+ if (mode !== "parallel") {
372
+ for (const t of running) {
373
+ if (mode === "pause") {
374
+ await postKronosysAction({
375
+ type: "setTaskTimerPaused",
376
+ taskId: t.id,
377
+ paused: true,
378
+ });
379
+ } else {
380
+ await postKronosysAction({
381
+ type: "finishTask",
382
+ taskId: t.id,
383
+ shouldCommit: false,
384
+ });
385
+ }
386
+ }
387
+ }
388
+ await post({
389
+ type: "startTask",
390
+ name: draft.name,
391
+ startKronoFocus: draft.startKronoFocus,
392
+ tags: draft.tags,
393
+ ...(draft.project ? { project: draft.project } : {}),
394
+ });
395
+ } catch (error) {
396
+ pushToast(
397
+ typeof error === "string"
398
+ ? error
399
+ : error instanceof Error
400
+ ? error.message
401
+ : "Failed to resolve concurrent tasks",
402
+ );
403
+ pendingLiveStartRef.current = null;
404
+ setConcurrentStartModalOpen(false);
405
+ setRememberConcurrentStart(false);
406
+ return;
407
+ }
408
+ pendingLiveStartRef.current = null;
409
+ setConcurrentStartModalOpen(false);
410
+ setRememberConcurrentStart(false);
411
+ clearTaskEntryForm();
412
+ },
413
+ [live, post, clearTaskEntryForm, pushToast],
414
+ );
415
+
416
+ const submitStartTask = useCallback(async () => {
417
+ const hasDraft = taskInput.trim().length > 0;
418
+ if (!hasDraft) {
419
+ return;
420
+ }
421
+ const payloadStart = buildStartTaskFromDraft(taskInput, [], undefined);
422
+ if (
423
+ !payloadStart.name.trim() &&
424
+ payloadStart.tags.length === 0 &&
425
+ !payloadStart.project
426
+ ) {
427
+ return;
428
+ }
429
+ const body = {
430
+ name: payloadStart.name,
431
+ tags: payloadStart.tags,
432
+ ...(payloadStart.project ? { project: payloadStart.project } : {}),
433
+ startKronoFocus: showKronoFocusInTaskOps && startKronoFocusWithTask,
434
+ };
435
+ if (!isInspecting && !hasLiveSession) {
436
+ pushToast(t.taskStartAutoSessionToast);
437
+ await post({ type: "newSession", sessionScope: undefined });
438
+ await post({
439
+ type: "startTask",
440
+ name: body.name,
441
+ startKronoFocus: body.startKronoFocus,
442
+ tags: body.tags,
443
+ ...(body.project ? { project: body.project } : {}),
444
+ });
445
+ clearTaskEntryForm();
446
+ return;
447
+ }
448
+ if (!isInspecting && hasLiveSession && live) {
449
+ const running = runningTasksFromSession(live);
450
+ if (running.length > 0) {
451
+ const pref = readConcurrentTaskStartPreference();
452
+ pendingLiveStartRef.current = body;
453
+ if (pref === "pause") {
454
+ void resolveConcurrentAndStartLive("pause", false);
455
+ return;
456
+ }
457
+ if (pref === "finish") {
458
+ void resolveConcurrentAndStartLive("finish", false);
459
+ return;
460
+ }
461
+ if (pref === "parallel") {
462
+ void resolveConcurrentAndStartLive("parallel", false);
463
+ return;
464
+ }
465
+ setRememberConcurrentStart(false);
466
+ setConcurrentStartModalOpen(true);
467
+ return;
468
+ }
469
+ }
470
+ await post({
471
+ type: "startTask",
472
+ name: body.name,
473
+ startKronoFocus: body.startKronoFocus,
474
+ tags: body.tags,
475
+ ...(body.project ? { project: body.project } : {}),
476
+ });
477
+ clearTaskEntryForm();
478
+ }, [
479
+ clearTaskEntryForm,
480
+ hasLiveSession,
481
+ isInspecting,
482
+ live,
483
+ post,
484
+ pushToast,
485
+ resolveConcurrentAndStartLive,
486
+ showKronoFocusInTaskOps,
487
+ startKronoFocusWithTask,
488
+ t.taskStartAutoSessionToast,
489
+ taskInput,
490
+ ]);
491
+
492
+ const cancelConcurrentStartModal = useCallback(() => {
493
+ pendingLiveStartRef.current = null;
494
+ setRememberConcurrentStart(false);
495
+ setConcurrentStartModalOpen(false);
496
+ }, []);
497
+
498
+ const retroTargetSessionId = inspectingId ?? live?.sessionId;
499
+
500
+ const submitAddHistoricalTask = useCallback(() => {
501
+ if (!retroTargetSessionId) {
502
+ return;
503
+ }
504
+ const nameRaw = taskInput;
505
+ const hasDraft = nameRaw.trim().length > 0;
506
+ if (!hasDraft) {
507
+ return;
508
+ }
509
+ const startMs = pastStartLocal.trim()
510
+ ? new Date(pastStartLocal).getTime()
511
+ : NaN;
512
+ const endMs = pastEndLocal.trim() ? new Date(pastEndLocal).getTime() : NaN;
513
+ if (
514
+ !Number.isFinite(startMs) ||
515
+ !Number.isFinite(endMs) ||
516
+ endMs <= startMs
517
+ ) {
518
+ setAlertMessage(t.archiveTaskDatetimeRangeInvalid);
519
+ return;
520
+ }
521
+ const durationMs = Math.round(endMs - startMs);
522
+ const payloadStart = buildStartTaskFromDraft(nameRaw, [], undefined);
523
+ if (
524
+ !payloadStart.name.trim() &&
525
+ payloadStart.tags.length === 0 &&
526
+ !payloadStart.project
527
+ ) {
528
+ return;
529
+ }
530
+ void post({
531
+ type: "addHistoricalTask",
532
+ sessionId: retroTargetSessionId,
533
+ name: payloadStart.name,
534
+ tags: payloadStart.tags,
535
+ durationMs,
536
+ startTime: new Date(startMs).toISOString(),
537
+ endTime: new Date(endMs).toISOString(),
538
+ ...(payloadStart.project ? { project: payloadStart.project } : {}),
539
+ });
540
+ setTaskInput("");
541
+ setPastStartLocal("");
542
+ setPastEndLocal("");
543
+ }, [
544
+ pastEndLocal,
545
+ pastStartLocal,
546
+ post,
547
+ retroTargetSessionId,
548
+ t.archiveTaskDatetimeRangeInvalid,
549
+ t.archiveTaskDatetimeRangeInvalid,
550
+ taskInput,
551
+ ]);
552
+
553
+ const pastRangeValid =
554
+ pastStartLocal.trim() !== "" &&
555
+ pastEndLocal.trim() !== "" &&
556
+ Number.isFinite(new Date(pastStartLocal).getTime()) &&
557
+ Number.isFinite(new Date(pastEndLocal).getTime()) &&
558
+ new Date(pastEndLocal).getTime() > new Date(pastStartLocal).getTime();
559
+
560
+ const canSubmitPast = !!taskInput.trim() && pastRangeValid;
561
+
562
+ /** Début + fin sur une seule ligne (scroll horizontal si besoin), contenu centré dans la zone. */
563
+ const pastDateTimeRow = (
564
+ <div className="flex min-w-0 max-w-full flex-nowrap items-center justify-center gap-x-2 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:thin] sm:gap-x-3 [&::-webkit-scrollbar]:h-1">
565
+ <label className="flex shrink-0 items-center gap-1.5">
566
+ <span className="whitespace-nowrap text-[0.6rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
567
+ {t.archiveTaskStartLabel}
568
+ </span>
569
+ <KronosysDatetimePopoverField
570
+ value={pastStartLocal}
571
+ onChange={setPastStartLocal}
572
+ aria-label={t.archiveTaskStartLabel}
573
+ lang={lang}
574
+ t={t}
575
+ />
576
+ </label>
577
+ <label className="flex shrink-0 items-center gap-1.5">
578
+ <span className="whitespace-nowrap text-[0.6rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
579
+ {t.archiveTaskEndLabel}
580
+ </span>
581
+ <KronosysDatetimePopoverField
582
+ value={pastEndLocal}
583
+ onChange={setPastEndLocal}
584
+ aria-label={t.archiveTaskEndLabel}
585
+ lang={lang}
586
+ t={t}
587
+ />
588
+ </label>
589
+ </div>
590
+ );
591
+
592
+ const pastDatetimeRowInspecting = (
593
+ <div className="flex w-full min-w-0 justify-center">
594
+ <div className="kronosys-past-datetime-blink-twice min-w-0 max-w-full">
595
+ {pastDateTimeRow}
596
+ </div>
597
+ </div>
598
+ );
599
+
600
+ const taskFormTagSection =
601
+ mergedDraftTags.length > 0 ? (
602
+ <div className="-mx-0.5 flex w-full min-w-0 flex-wrap items-start gap-x-2 gap-y-1.5 px-0.5 pb-0.5">
603
+ <TagPills
604
+ variant="applied"
605
+ className="flex min-w-0 max-w-full flex-wrap items-baseline gap-x-1 gap-y-0.5"
606
+ differentiateProjectScopedTags
607
+ tags={mergedDraftTags}
608
+ onRemove={handleRemoveDraftTag}
609
+ defaultTagBucketLabel={t.taskTagDefaultBucket}
610
+ />
611
+ </div>
612
+ ) : null;
613
+
614
+ const resumeLiveSession = async () => {
615
+ if (onResumeActiveSession) {
616
+ await onResumeActiveSession();
617
+ return;
618
+ }
619
+ await post({ type: "inspectSession", sessionId: null });
620
+ await post({ type: "setPaused", paused: false });
621
+ };
622
+
623
+ const historyBannerTitle = isPauseState
624
+ ? t.pausedNote
625
+ : t.viewingHistoryBannerTitle;
626
+ const viewingSessionLabel =
627
+ sessionCurrent?.sessionName?.trim() || sessionCurrent?.sessionId || "—";
628
+
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
+ const showTaskModeToggle = !!retroTargetSessionId;
676
+ const effectiveTaskEntryMode: TaskEntryMode = showTaskModeToggle
677
+ ? taskEntryMode
678
+ : "realtime";
679
+
680
+ return (
681
+ <section id="kronosys-task-focus" className="scroll-mt-24">
682
+ {isInspecting && (
683
+ <section
684
+ className="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"
685
+ aria-label={t.historyInspectBannerAria}
686
+ >
687
+ <div className="flex min-w-0 items-start gap-2">
688
+ <Pause
689
+ className="mt-0.5 shrink-0 text-amber-400"
690
+ size={18}
691
+ aria-hidden
692
+ />
693
+ <div className="min-w-0">
694
+ <strong className="block text-sm text-amber-200">
695
+ {historyBannerTitle}
696
+ </strong>
697
+ <div className="mt-1 break-all text-xs font-normal text-amber-300/75">
698
+ {t.inspectingLabel} {viewingSessionLabel}
699
+ </div>
700
+ </div>
701
+ </div>
702
+ <button
703
+ type="button"
704
+ 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"
705
+ onClick={() => void resumeLiveSession()}
706
+ >
707
+ {t.resumeBtn}
708
+ </button>
709
+ </section>
710
+ )}
711
+
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
+ />
739
+ </div>
740
+ ) : null}
741
+ </div>
742
+ ) : null}
743
+
744
+ <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" ? (
756
+ <div className="kronosys-past-datetime-blink-twice min-w-0 max-w-full">
757
+ {pastDateTimeRow}
758
+ </div>
759
+ ) : null}
760
+ </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"
766
+ >
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}
775
+ </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
+ <button
790
+ 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()}
795
+ >
796
+ <Ticket size={15} />
797
+ </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
+ />
811
+ </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}
849
+ <button
850
+ type="button"
851
+ className={tbVioletIcon}
852
+ disabled={!canSubmitPast}
853
+ title={t.archiveAddTaskBtn}
854
+ aria-label={t.archiveAddTaskBtn}
855
+ onClick={() => void submitAddHistoricalTask()}
856
+ >
857
+ <Play
858
+ size={22}
859
+ className="ml-0.5"
860
+ fill="currentColor"
861
+ aria-hidden
862
+ />
863
+ </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)}
875
+ >
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>
889
+ </div>
890
+ ) : 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
934
+ }
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
962
+ }
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}
992
+ <button
993
+ type="button"
994
+ className={tbVioletIcon}
995
+ disabled={!taskInput.trim()}
996
+ title={t.startTaskBtn}
997
+ aria-label={t.startTaskBtn}
998
+ onClick={() => void submitStartTask()}
999
+ >
1000
+ <Play
1001
+ size={22}
1002
+ className="ml-0.5"
1003
+ fill="currentColor"
1004
+ aria-hidden
1005
+ />
1006
+ </button>
1007
+ </>
1008
+ ) : (
1009
+ <button
1010
+ type="button"
1011
+ className={tbVioletIcon}
1012
+ disabled={!canSubmitPast || !retroTargetSessionId}
1013
+ title={t.archiveAddTaskBtn}
1014
+ aria-label={t.archiveAddTaskBtn}
1015
+ onClick={() => void submitAddHistoricalTask()}
1016
+ >
1017
+ <Play
1018
+ size={22}
1019
+ className="ml-0.5"
1020
+ fill="currentColor"
1021
+ aria-hidden
1022
+ />
1023
+ </button>
1024
+ )}
1025
+ </div>
1026
+ </div>
1027
+ {taskFormTagSection}
1028
+ </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>
1042
+ </div>
1043
+ <div className="divide-y divide-zinc-200/80 dark:divide-zinc-700/80">
1044
+ {runningTasks.map((task) => (
1045
+ <TaskSessionLiveCard
1046
+ key={task.id}
1047
+ variant="plain"
1048
+ task={task}
1049
+ lang={lang}
1050
+ displayTimeZone={displayTimeZone}
1051
+ use24HourClock={use24HourClock}
1052
+ isInspecting={isInspecting}
1053
+ inspectingId={inspectingId}
1054
+ knownTags={knownTags}
1055
+ knownProjects={knownProjects}
1056
+ post={post}
1057
+ t={t}
1058
+ confirmDeleteTask={confirmDeleteTask}
1059
+ kronoFocusIsRunningOrPaused={kronoFocusIsRunningOrPaused}
1060
+ showKronoFocusTaskActions={showKronoFocusInTaskOps}
1061
+ startKronoFocusFromTask={() =>
1062
+ void startKronoFocusFromTask()
1063
+ }
1064
+ pausePlayMode="pause"
1065
+ onPausePlay={() =>
1066
+ void post({
1067
+ type: "setTaskTimerPaused",
1068
+ taskId: task.id,
1069
+ paused: true,
1070
+ })
1071
+ }
1072
+ anchorId={`kronosys-active-task-${task.id}`}
1073
+ allowTaskStartTimeEdit={allowTaskStartTimeEdit}
1074
+ allowTaskEndTimeEdit={allowTaskEndTimeEdit}
1075
+ />
1076
+ ))}
1077
+ </div>
1078
+ </div>
1079
+ ) : null}
1080
+ </>
1081
+ </div>
1082
+
1083
+ {showTaskListBuckets ? (
1084
+ <div className="mt-8 space-y-10">
1085
+ {showSessionTaskCountBadge ? (
1086
+ <div className="flex justify-end">
1087
+ <span className="rounded-full border border-zinc-600 px-2 py-0.5 text-xs text-zinc-400">
1088
+ {mergedTasksForBuckets.length}
1089
+ </span>
1090
+ </div>
1091
+ ) : null}
1092
+
1093
+ {showPausedTaskSection ? (
1094
+ <div className="space-y-2">
1095
+ <div className="flex items-center justify-between gap-2">
1096
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
1097
+ {t.tasksPausedHeading}
1098
+ </h4>
1099
+ <span className="rounded-full border border-zinc-600/80 px-2 py-0.5 text-[0.65rem] text-zinc-500">
1100
+ {pausedTasksDisplay.length}
1101
+ </span>
1102
+ </div>
1103
+ <div className="space-y-3">
1104
+ {pausedTasksDisplay.map((task) => (
1105
+ <TaskSessionLiveCard
1106
+ 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
+ />
1131
+ ))}
1132
+ </div>
1133
+ </div>
1134
+ ) : null}
1135
+
1136
+ {showCompletedTaskSection ? (
1137
+ <div className="space-y-2">
1138
+ <div className="flex items-center justify-between gap-2">
1139
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
1140
+ {t.tasksCompletedHeading}
1141
+ </h4>
1142
+ <span className="rounded-full border border-zinc-600/80 px-2 py-0.5 text-[0.65rem] text-zinc-500">
1143
+ {completedTasksDisplay.length}
1144
+ </span>
1145
+ </div>
1146
+ <div className="space-y-3">
1147
+ {completedTasksDisplay.map((task) => (
1148
+ <TaskSessionLiveCard
1149
+ 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
+ />
1172
+ ))}
1173
+ </div>
1174
+ </div>
1175
+ ) : null}
1176
+ </div>
1177
+ ) : null}
1178
+
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
+ <DashboardAlertModal
1193
+ open={alertMessage !== null}
1194
+ message={alertMessage ?? ""}
1195
+ okLabel={t.dialogOkBtn}
1196
+ onClose={() => setAlertMessage(null)}
1197
+ />
1198
+ <DashboardConfirmModal
1199
+ open={kronoFocusResetConfirmOpen}
1200
+ message={t.startKronoFocusFromTaskConfirm}
1201
+ cancelLabel={t.dialogCancelBtn}
1202
+ confirmLabel={t.dialogConfirmBtn}
1203
+ onCancel={() => setKronoFocusResetConfirmOpen(false)}
1204
+ onConfirm={executeKronoFocusResetAndStart}
1205
+ />
1206
+ <DashboardConfirmModal
1207
+ open={deleteTaskConfirmId !== null}
1208
+ message={t.taskDeleteConfirm}
1209
+ cancelLabel={t.dialogCancelBtn}
1210
+ confirmLabel={t.dialogConfirmBtn}
1211
+ confirmVariant="danger"
1212
+ onCancel={() => setDeleteTaskConfirmId(null)}
1213
+ onConfirm={() => {
1214
+ const id = deleteTaskConfirmId;
1215
+ setDeleteTaskConfirmId(null);
1216
+ if (id) {
1217
+ void post({
1218
+ type: "deleteTask",
1219
+ taskId: id,
1220
+ sessionId: inspectingId,
1221
+ });
1222
+ }
1223
+ }}
1224
+ />
1225
+ <DashboardTriActionModal
1226
+ open={concurrentStartModalOpen}
1227
+ title={t.taskConcurrentTrackingConflictTitle}
1228
+ message={t.taskConcurrentTrackingConflictMessage}
1229
+ dismissLabel={t.dialogCancelBtn}
1230
+ tertiaryLabel={t.taskConcurrentTrackingConflictParallelBtn}
1231
+ secondaryLabel={t.taskConcurrentTrackingConflictPauseBtn}
1232
+ primaryLabel={t.taskConcurrentTrackingConflictFinishBtn}
1233
+ primaryVariant="danger"
1234
+ dismissCheckbox={{
1235
+ label: t.taskConcurrentTrackingDontShowAgain,
1236
+ checked: rememberConcurrentStart,
1237
+ onChange: setRememberConcurrentStart,
1238
+ }}
1239
+ onDismiss={cancelConcurrentStartModal}
1240
+ onTertiary={() =>
1241
+ void resolveConcurrentAndStartLive(
1242
+ "parallel",
1243
+ rememberConcurrentRef.current,
1244
+ )
1245
+ }
1246
+ onSecondary={() =>
1247
+ void resolveConcurrentAndStartLive(
1248
+ "pause",
1249
+ rememberConcurrentRef.current,
1250
+ )
1251
+ }
1252
+ onPrimary={() =>
1253
+ void resolveConcurrentAndStartLive(
1254
+ "finish",
1255
+ rememberConcurrentRef.current,
1256
+ )
1257
+ }
1258
+ />
1259
+ </section>
1260
+ );
1261
+ }