agentic-orchestrator 0.1.26 → 0.1.28

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 (172) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +47 -14
  4. package/agentic/orchestrator/agents.yaml +13 -0
  5. package/agentic/orchestrator/policy.yaml +3 -0
  6. package/agentic/orchestrator/schemas/agents.schema.json +76 -0
  7. package/agentic/orchestrator/schemas/policy.schema.json +16 -0
  8. package/agentic/orchestrator/schemas/policy.user.schema.json +16 -0
  9. package/agentic/orchestrator/schemas/state.schema.json +53 -0
  10. package/apps/control-plane/src/application/configuration-service.ts +181 -0
  11. package/apps/control-plane/src/application/kernel-tool-wiring.ts +292 -0
  12. package/apps/control-plane/src/application/services/checkpoint-service.ts +523 -0
  13. package/apps/control-plane/src/application/services/feature-send-message-service.ts +132 -0
  14. package/apps/control-plane/src/application/services/patch-service.ts +29 -5
  15. package/apps/control-plane/src/application/services/repo-operations-service.ts +276 -0
  16. package/apps/control-plane/src/application/services/worktree-watchdog-service.ts +156 -0
  17. package/apps/control-plane/src/cli/cli-argument-parser.ts +12 -0
  18. package/apps/control-plane/src/cli/help-command-handler.ts +17 -0
  19. package/apps/control-plane/src/cli/init-command-handler.ts +31 -0
  20. package/apps/control-plane/src/cli/resume-command-handler.ts +31 -4
  21. package/apps/control-plane/src/cli/rollback-command-handler.ts +217 -0
  22. package/apps/control-plane/src/cli/run-command-handler.ts +8 -0
  23. package/apps/control-plane/src/cli/types.ts +3 -0
  24. package/apps/control-plane/src/core/kernel-types.ts +55 -0
  25. package/apps/control-plane/src/core/kernel.ts +61 -878
  26. package/apps/control-plane/src/core/tool-caller.ts +10 -0
  27. package/apps/control-plane/src/core/utils/field-readers.ts +38 -0
  28. package/apps/control-plane/src/core/utils/index-normalizer.ts +119 -0
  29. package/apps/control-plane/src/core/utils/path-normalizers.ts +22 -0
  30. package/apps/control-plane/src/interfaces/cli/bootstrap.ts +15 -0
  31. package/apps/control-plane/src/providers/api-worker-provider.ts +14 -12
  32. package/apps/control-plane/src/providers/cli-worker-provider.ts +82 -12
  33. package/apps/control-plane/src/providers/providers.ts +45 -24
  34. package/apps/control-plane/src/providers/worker-provider-factory.ts +36 -1
  35. package/apps/control-plane/src/supervisor/run-coordinator.ts +91 -36
  36. package/apps/control-plane/src/supervisor/runtime.ts +107 -1
  37. package/apps/control-plane/src/supervisor/types.ts +9 -0
  38. package/apps/control-plane/src/supervisor/worker-decision-loop.ts +253 -14
  39. package/apps/control-plane/test/checkpoint-service.spec.ts +537 -0
  40. package/apps/control-plane/test/cli-helpers.spec.ts +28 -0
  41. package/apps/control-plane/test/cli.unit.spec.ts +52 -0
  42. package/apps/control-plane/test/configuration-service.spec.ts +466 -0
  43. package/apps/control-plane/test/dashboard-api.integration.spec.ts +537 -0
  44. package/apps/control-plane/test/dashboard-client.spec.ts +233 -0
  45. package/apps/control-plane/test/feature-send-message-service.spec.ts +314 -0
  46. package/apps/control-plane/test/init-wizard.spec.ts +35 -0
  47. package/apps/control-plane/test/path-normalizers.spec.ts +41 -0
  48. package/apps/control-plane/test/repo-operations-service.spec.ts +339 -0
  49. package/apps/control-plane/test/resume-command.spec.ts +33 -0
  50. package/apps/control-plane/test/review-workspace-logic.spec.ts +130 -0
  51. package/apps/control-plane/test/rollback-command.spec.ts +208 -0
  52. package/apps/control-plane/test/run-coordinator.spec.ts +119 -0
  53. package/apps/control-plane/test/worker-decision-loop.spec.ts +209 -0
  54. package/apps/control-plane/test/worker-provider-adapters.spec.ts +102 -0
  55. package/apps/control-plane/test/worker-provider-factory.spec.ts +14 -0
  56. package/apps/control-plane/test/worktree-watchdog-service.spec.ts +147 -0
  57. package/config/agentic/orchestrator/agents.yaml +13 -0
  58. package/dist/apps/control-plane/application/configuration-service.d.ts +19 -0
  59. package/dist/apps/control-plane/application/configuration-service.js +123 -0
  60. package/dist/apps/control-plane/application/configuration-service.js.map +1 -0
  61. package/dist/apps/control-plane/application/kernel-tool-wiring.d.ts +39 -0
  62. package/dist/apps/control-plane/application/kernel-tool-wiring.js +38 -0
  63. package/dist/apps/control-plane/application/kernel-tool-wiring.js.map +1 -0
  64. package/dist/apps/control-plane/application/services/checkpoint-service.d.ts +84 -0
  65. package/dist/apps/control-plane/application/services/checkpoint-service.js +367 -0
  66. package/dist/apps/control-plane/application/services/checkpoint-service.js.map +1 -0
  67. package/dist/apps/control-plane/application/services/feature-send-message-service.d.ts +25 -0
  68. package/dist/apps/control-plane/application/services/feature-send-message-service.js +105 -0
  69. package/dist/apps/control-plane/application/services/feature-send-message-service.js.map +1 -0
  70. package/dist/apps/control-plane/application/services/patch-service.d.ts +6 -0
  71. package/dist/apps/control-plane/application/services/patch-service.js +11 -2
  72. package/dist/apps/control-plane/application/services/patch-service.js.map +1 -1
  73. package/dist/apps/control-plane/application/services/repo-operations-service.d.ts +70 -0
  74. package/dist/apps/control-plane/application/services/repo-operations-service.js +213 -0
  75. package/dist/apps/control-plane/application/services/repo-operations-service.js.map +1 -0
  76. package/dist/apps/control-plane/application/services/worktree-watchdog-service.d.ts +23 -0
  77. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js +119 -0
  78. package/dist/apps/control-plane/application/services/worktree-watchdog-service.js.map +1 -0
  79. package/dist/apps/control-plane/cli/cli-argument-parser.js +12 -0
  80. package/dist/apps/control-plane/cli/cli-argument-parser.js.map +1 -1
  81. package/dist/apps/control-plane/cli/help-command-handler.js +17 -0
  82. package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
  83. package/dist/apps/control-plane/cli/init-command-handler.js +23 -0
  84. package/dist/apps/control-plane/cli/init-command-handler.js.map +1 -1
  85. package/dist/apps/control-plane/cli/resume-command-handler.js +25 -5
  86. package/dist/apps/control-plane/cli/resume-command-handler.js.map +1 -1
  87. package/dist/apps/control-plane/cli/rollback-command-handler.d.ts +6 -0
  88. package/dist/apps/control-plane/cli/rollback-command-handler.js +177 -0
  89. package/dist/apps/control-plane/cli/rollback-command-handler.js.map +1 -0
  90. package/dist/apps/control-plane/cli/run-command-handler.js +7 -1
  91. package/dist/apps/control-plane/cli/run-command-handler.js.map +1 -1
  92. package/dist/apps/control-plane/cli/types.d.ts +3 -0
  93. package/dist/apps/control-plane/cli/types.js +1 -0
  94. package/dist/apps/control-plane/cli/types.js.map +1 -1
  95. package/dist/apps/control-plane/core/configuration-service.d.ts +25 -0
  96. package/dist/apps/control-plane/core/configuration-service.js +130 -0
  97. package/dist/apps/control-plane/core/configuration-service.js.map +1 -0
  98. package/dist/apps/control-plane/core/kernel-tool-wiring.d.ts +50 -0
  99. package/dist/apps/control-plane/core/kernel-tool-wiring.js +44 -0
  100. package/dist/apps/control-plane/core/kernel-tool-wiring.js.map +1 -0
  101. package/dist/apps/control-plane/core/kernel-types.d.ts +48 -0
  102. package/dist/apps/control-plane/core/kernel-types.js +2 -0
  103. package/dist/apps/control-plane/core/kernel-types.js.map +1 -0
  104. package/dist/apps/control-plane/core/kernel.d.ts +17 -48
  105. package/dist/apps/control-plane/core/kernel.js +44 -539
  106. package/dist/apps/control-plane/core/kernel.js.map +1 -1
  107. package/dist/apps/control-plane/core/tool-caller.d.ts +10 -0
  108. package/dist/apps/control-plane/core/utils/error-normalizer.d.ts +2 -0
  109. package/dist/apps/control-plane/core/utils/error-normalizer.js +51 -0
  110. package/dist/apps/control-plane/core/utils/error-normalizer.js.map +1 -0
  111. package/dist/apps/control-plane/core/utils/field-readers.d.ts +9 -0
  112. package/dist/apps/control-plane/core/utils/field-readers.js +30 -0
  113. package/dist/apps/control-plane/core/utils/field-readers.js.map +1 -0
  114. package/dist/apps/control-plane/core/utils/index-normalizer.d.ts +7 -0
  115. package/dist/apps/control-plane/core/utils/index-normalizer.js +92 -0
  116. package/dist/apps/control-plane/core/utils/index-normalizer.js.map +1 -0
  117. package/dist/apps/control-plane/core/utils/path-normalizers.d.ts +2 -0
  118. package/dist/apps/control-plane/core/utils/path-normalizers.js +17 -0
  119. package/dist/apps/control-plane/core/utils/path-normalizers.js.map +1 -0
  120. package/dist/apps/control-plane/interfaces/cli/bootstrap.js +13 -1
  121. package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
  122. package/dist/apps/control-plane/providers/api-worker-provider.d.ts +4 -13
  123. package/dist/apps/control-plane/providers/api-worker-provider.js +10 -0
  124. package/dist/apps/control-plane/providers/api-worker-provider.js.map +1 -1
  125. package/dist/apps/control-plane/providers/cli-worker-provider.d.ts +11 -13
  126. package/dist/apps/control-plane/providers/cli-worker-provider.js +64 -0
  127. package/dist/apps/control-plane/providers/cli-worker-provider.js.map +1 -1
  128. package/dist/apps/control-plane/providers/providers.d.ts +31 -24
  129. package/dist/apps/control-plane/providers/providers.js +10 -0
  130. package/dist/apps/control-plane/providers/providers.js.map +1 -1
  131. package/dist/apps/control-plane/providers/worker-provider-factory.d.ts +11 -0
  132. package/dist/apps/control-plane/providers/worker-provider-factory.js +20 -1
  133. package/dist/apps/control-plane/providers/worker-provider-factory.js.map +1 -1
  134. package/dist/apps/control-plane/supervisor/run-coordinator.d.ts +3 -0
  135. package/dist/apps/control-plane/supervisor/run-coordinator.js +81 -33
  136. package/dist/apps/control-plane/supervisor/run-coordinator.js.map +1 -1
  137. package/dist/apps/control-plane/supervisor/runtime.d.ts +8 -1
  138. package/dist/apps/control-plane/supervisor/runtime.js +90 -0
  139. package/dist/apps/control-plane/supervisor/runtime.js.map +1 -1
  140. package/dist/apps/control-plane/supervisor/types.d.ts +11 -0
  141. package/dist/apps/control-plane/supervisor/types.js.map +1 -1
  142. package/dist/apps/control-plane/supervisor/worker-decision-loop.d.ts +21 -1
  143. package/dist/apps/control-plane/supervisor/worker-decision-loop.js +207 -13
  144. package/dist/apps/control-plane/supervisor/worker-decision-loop.js.map +1 -1
  145. package/package.json +1 -1
  146. package/packages/web-dashboard/package.json +2 -0
  147. package/packages/web-dashboard/src/app/analytics/page.tsx +83 -2
  148. package/packages/web-dashboard/src/app/api/actions/route.ts +92 -1
  149. package/packages/web-dashboard/src/app/api/analytics/route.ts +5 -2
  150. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/[checkpointId]/diff/route.ts +43 -0
  151. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/compare/route.ts +45 -0
  152. package/packages/web-dashboard/src/app/api/features/[id]/checkpoints/stream/route.ts +170 -0
  153. package/packages/web-dashboard/src/app/api/features/[id]/file-diff/route.ts +144 -0
  154. package/packages/web-dashboard/src/app/api/features/[id]/log-stream/route.ts +167 -0
  155. package/packages/web-dashboard/src/app/api/features/[id]/raw-logs/[filename]/route.ts +65 -0
  156. package/packages/web-dashboard/src/app/api/features/[id]/raw-logs/route.ts +63 -0
  157. package/packages/web-dashboard/src/app/api/features/[id]/timeline/route.ts +60 -0
  158. package/packages/web-dashboard/src/app/feature/[id]/page.tsx +32 -11
  159. package/packages/web-dashboard/src/app/globals.css +2 -0
  160. package/packages/web-dashboard/src/components/detail-panel.tsx +483 -0
  161. package/packages/web-dashboard/src/components/review-workspace.tsx +1162 -0
  162. package/packages/web-dashboard/src/lib/aop-client.ts +725 -0
  163. package/packages/web-dashboard/src/lib/review-contracts.ts +182 -0
  164. package/packages/web-dashboard/src/lib/review-workspace-logic.ts +64 -0
  165. package/packages/web-dashboard/src/lib/types.ts +131 -0
  166. package/packages/web-dashboard/src/styles/dashboard.module.css +333 -0
  167. package/spec-files/completed/agentic_orchestrator_execution_mode_spec.md +1905 -0
  168. package/spec-files/outstanding/agentic_orchestrator_runtime_inspection_spec.md +940 -0
  169. package/spec-files/outstanding/execution_mode_critical_review.md +355 -0
  170. package/spec-files/outstanding/shadow_workspace_implementation_spec.md +1271 -0
  171. package/spec-files/outstanding/shadow_workspace_spec_summary.md +222 -0
  172. package/spec-files/progress.md +269 -1
@@ -0,0 +1,1162 @@
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ type KeyboardEvent as ReactKeyboardEvent,
7
+ } from 'react';
8
+ import dynamic from 'next/dynamic';
9
+ import { html as renderDiffHtml } from 'diff2html';
10
+ import DOMPurify from 'dompurify';
11
+ import { projectQuery, parseUnifiedDiff } from '@/lib/dashboard-utils.js';
12
+ import { isEditableTarget } from '@/lib/review-contracts.js';
13
+ import {
14
+ appendCappedLogEntries,
15
+ isConsoleSendAllowed,
16
+ stepHunkIndex,
17
+ } from '@/lib/review-workspace-logic.js';
18
+ import type {
19
+ AgentLogEntry,
20
+ AgentPipelineStatus,
21
+ FeatureCheckpoint,
22
+ FeatureDetail,
23
+ FileDiffPayload,
24
+ RawLogFileMeta,
25
+ RoleStatus,
26
+ WorkerEventEntry,
27
+ } from '@/lib/types.js';
28
+ import styles from '@/styles/dashboard.module.css';
29
+
30
+ const MonacoDiffEditor = dynamic(
31
+ async () => {
32
+ const module = await import('@monaco-editor/react');
33
+ return module.DiffEditor;
34
+ },
35
+ { ssr: false },
36
+ );
37
+
38
+ type ReviewAction =
39
+ | 'review.approve'
40
+ | 'review.deny'
41
+ | 'review.request_changes'
42
+ | 'feature.send_message';
43
+ type DiffRenderMode = 'line-by-line' | 'side-by-side';
44
+ type TimelineValidity = 'all' | 'valid' | 'invalid';
45
+ type TrustClass = 'Structured Artifact' | 'Raw Provider Output' | 'Derived';
46
+ type PipelineRole = 'planner' | 'builder' | 'qa';
47
+ type RuntimeInspectorTab = 'activity' | 'checkpoints';
48
+
49
+ interface ActionResponse {
50
+ ok: boolean;
51
+ error?: { code?: string; message?: string };
52
+ }
53
+
54
+ interface ApiEnvelope<TData> {
55
+ ok: boolean;
56
+ data?: TData;
57
+ error?: { code?: string; message?: string };
58
+ }
59
+
60
+ interface LogStreamPayload {
61
+ type: 'snapshot' | 'append' | 'heartbeat' | 'error';
62
+ cursor: string;
63
+ entries: AgentLogEntry[];
64
+ message?: string;
65
+ }
66
+
67
+ interface ReviewWorkspaceProps {
68
+ detail: FeatureDetail;
69
+ project: string;
70
+ hasActiveRuntimeSession: boolean;
71
+ refreshDetail: (featureId: string) => Promise<void>;
72
+ refreshStatus: () => Promise<void>;
73
+ addToast: (message: string, type?: 'success' | 'error' | 'info') => void;
74
+ }
75
+
76
+ interface ActiveRoleMarker {
77
+ role: PipelineRole | null;
78
+ status: RoleStatus | null;
79
+ }
80
+
81
+ function truncateSessionId(value: string): string {
82
+ if (value.length <= 18) {
83
+ return value;
84
+ }
85
+ return `${value.slice(0, 8)}…${value.slice(-8)}`;
86
+ }
87
+
88
+ function formatTimestamp(value: string): string {
89
+ const parsed = Date.parse(value);
90
+ if (Number.isNaN(parsed)) {
91
+ return value;
92
+ }
93
+ return new Date(parsed).toLocaleTimeString([], {
94
+ hour: '2-digit',
95
+ minute: '2-digit',
96
+ second: '2-digit',
97
+ });
98
+ }
99
+
100
+ function formatCheckpointTimestamp(value: string): string {
101
+ const parsed = Date.parse(value);
102
+ if (Number.isNaN(parsed)) {
103
+ return value;
104
+ }
105
+ return new Date(parsed).toLocaleString([], {
106
+ month: 'short',
107
+ day: '2-digit',
108
+ hour: '2-digit',
109
+ minute: '2-digit',
110
+ second: '2-digit',
111
+ });
112
+ }
113
+
114
+ function checkpointStatusBadge(
115
+ checkpoint: FeatureCheckpoint,
116
+ stylesMap: Record<string, string>,
117
+ ): string {
118
+ if (checkpoint.validation_status === 'valid') {
119
+ return `${stylesMap.badge} ${stylesMap.badgePass}`;
120
+ }
121
+ if (checkpoint.validation_status === 'invalid') {
122
+ return `${stylesMap.badge} ${stylesMap.badgeFail}`;
123
+ }
124
+ return `${stylesMap.badge} ${stylesMap.badgePending}`;
125
+ }
126
+
127
+ function checkpointSeverityBadge(
128
+ severity: FeatureCheckpoint['severity'],
129
+ stylesMap: Record<string, string>,
130
+ ): string {
131
+ if (severity === 'error' || severity === 'critical') {
132
+ return `${stylesMap.badge} ${stylesMap.badgeFail}`;
133
+ }
134
+ if (severity === 'warning') {
135
+ return `${stylesMap.badge} ${stylesMap.badgePending}`;
136
+ }
137
+ return `${stylesMap.badge} ${stylesMap.badgeNeutral}`;
138
+ }
139
+
140
+ function hasActiveSession(value: string | undefined): boolean {
141
+ return Boolean(value) && value !== 'unknown' && value !== 'unassigned' && value !== 'none';
142
+ }
143
+
144
+ function resolveActiveRole(roleStatus: AgentPipelineStatus | undefined): ActiveRoleMarker {
145
+ if (!roleStatus) {
146
+ return { role: null, status: null };
147
+ }
148
+ const entries: Array<{ role: PipelineRole; status: RoleStatus }> = [
149
+ { role: 'planner', status: roleStatus.planner },
150
+ { role: 'builder', status: roleStatus.builder },
151
+ { role: 'qa', status: roleStatus.qa },
152
+ ];
153
+ const priorities: RoleStatus[] = ['running', 'blocked', 'ready', 'done', 'unknown'];
154
+ for (const priority of priorities) {
155
+ const match = entries.find((entry) => entry.status === priority);
156
+ if (match) {
157
+ return { role: match.role, status: match.status };
158
+ }
159
+ }
160
+ return { role: null, status: null };
161
+ }
162
+
163
+ async function callAction(
164
+ action: ReviewAction,
165
+ featureId: string,
166
+ project: string,
167
+ payload: Record<string, unknown>,
168
+ ): Promise<ActionResponse> {
169
+ const response = await fetch(`/api/actions${projectQuery(project)}`, {
170
+ method: 'POST',
171
+ headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ action, feature_id: featureId, ...payload }),
173
+ });
174
+ return (await response.json()) as ActionResponse;
175
+ }
176
+
177
+ export function ReviewWorkspace({
178
+ detail,
179
+ project,
180
+ hasActiveRuntimeSession,
181
+ refreshDetail,
182
+ refreshStatus,
183
+ addToast,
184
+ }: ReviewWorkspaceProps) {
185
+ const featureId = detail.feature.feature_id;
186
+ const files = useMemo(() => parseUnifiedDiff(detail.diff), [detail.diff]);
187
+ const filePaths = useMemo(
188
+ () =>
189
+ files.map((file) => ({
190
+ path: file.filePath,
191
+ hunkCount: file.hunks.length,
192
+ })),
193
+ [files],
194
+ );
195
+ const diffStats = useMemo(() => {
196
+ let additions = 0;
197
+ let deletions = 0;
198
+ for (const file of files) {
199
+ for (const hunk of file.hunks) {
200
+ for (const line of hunk.lines) {
201
+ if (line.type === 'add') {
202
+ additions += 1;
203
+ }
204
+ if (line.type === 'del') {
205
+ deletions += 1;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ return { filesChanged: files.length, additions, deletions };
211
+ }, [files]);
212
+
213
+ const [submitting, setSubmitting] = useState(false);
214
+ const [reviewMessage, setReviewMessage] = useState('');
215
+ const [approvalToken, setApprovalToken] = useState('approved');
216
+ const [mergeStrategy, setMergeStrategy] = useState<'merge_commit' | 'squash' | 'rebase'>(
217
+ 'merge_commit',
218
+ );
219
+ const [diffMode, setDiffMode] = useState<DiffRenderMode>('line-by-line');
220
+ const [selectedFileIndex, setSelectedFileIndex] = useState(0);
221
+ const [selectedHunkIndex, setSelectedHunkIndex] = useState(0);
222
+ const [diffShortcutsVisible, setDiffShortcutsVisible] = useState(false);
223
+ const [inspectorOpen, setInspectorOpen] = useState(false);
224
+ const [inspectorLoading, setInspectorLoading] = useState(false);
225
+ const [inspectorError, setInspectorError] = useState<string | null>(null);
226
+ const [inspectorPayload, setInspectorPayload] = useState<FileDiffPayload | null>(null);
227
+ const [streamEntries, setStreamEntries] = useState<AgentLogEntry[]>(detail.log_entries ?? []);
228
+ const [streamFollow, setStreamFollow] = useState(true);
229
+ const [streamError, setStreamError] = useState<string | null>(null);
230
+ const [consoleMessage, setConsoleMessage] = useState('');
231
+ const [consoleTranscript, setConsoleTranscript] = useState<
232
+ Array<{ id: string; timestamp: string; text: string }>
233
+ >([]);
234
+ const [lastMessageSentAt, setLastMessageSentAt] = useState<number>(0);
235
+ const [timelineEntries, setTimelineEntries] = useState<WorkerEventEntry[]>([]);
236
+ const [timelineRole, setTimelineRole] = useState<string>('');
237
+ const [timelineValidity, setTimelineValidity] = useState<TimelineValidity>('all');
238
+ const [timelineLoading, setTimelineLoading] = useState(false);
239
+ const [timelineError, setTimelineError] = useState<string | null>(null);
240
+ const [runtimeInspectorTab, setRuntimeInspectorTab] = useState<RuntimeInspectorTab>('activity');
241
+ const [rawLogsEnabled, setRawLogsEnabled] = useState<boolean | null>(null);
242
+ const [rawLogs, setRawLogs] = useState<RawLogFileMeta[]>([]);
243
+ const [rawLogSelection, setRawLogSelection] = useState<string | null>(null);
244
+ const [rawLogContent, setRawLogContent] = useState<string>('');
245
+ const [rawLogError, setRawLogError] = useState<string | null>(null);
246
+ const [isPhoneViewport, setIsPhoneViewport] = useState(false);
247
+
248
+ const diffHtml = useMemo(() => {
249
+ if (detail.diff.trim().length === 0) {
250
+ return '';
251
+ }
252
+ const rendered = renderDiffHtml(detail.diff, {
253
+ drawFileList: true,
254
+ outputFormat: diffMode,
255
+ matching: 'lines',
256
+ renderNothingWhenEmpty: false,
257
+ });
258
+ return DOMPurify.sanitize(rendered, { USE_PROFILES: { html: true } });
259
+ }, [detail.diff, diffMode]);
260
+
261
+ const diffRegionRef = useRef<HTMLDivElement>(null);
262
+ const diffContentRef = useRef<HTMLDivElement>(null);
263
+ const logListRef = useRef<HTMLDivElement>(null);
264
+ const streamCursorRef = useRef<string>('0');
265
+
266
+ useEffect(() => {
267
+ if (selectedFileIndex >= filePaths.length) {
268
+ setSelectedFileIndex(0);
269
+ setSelectedHunkIndex(0);
270
+ }
271
+ }, [filePaths.length, selectedFileIndex]);
272
+
273
+ useEffect(() => {
274
+ setStreamEntries(detail.log_entries ?? []);
275
+ streamCursorRef.current = '0';
276
+ }, [featureId, detail.log_entries]);
277
+
278
+ useEffect(() => {
279
+ if (!streamFollow) {
280
+ return;
281
+ }
282
+ const container = logListRef.current;
283
+ if (!container) {
284
+ return;
285
+ }
286
+ container.scrollTop = container.scrollHeight;
287
+ }, [streamEntries, streamFollow]);
288
+
289
+ useEffect(() => {
290
+ const container = diffContentRef.current;
291
+ if (!container) {
292
+ return;
293
+ }
294
+ const fileWrappers = container.querySelectorAll<HTMLElement>('.d2h-file-wrapper');
295
+ const fileWrapper = fileWrappers[selectedFileIndex];
296
+ if (!fileWrapper) {
297
+ return;
298
+ }
299
+ const hunkHeaders = fileWrapper.querySelectorAll<HTMLElement>('tr.d2h-info');
300
+ const target =
301
+ hunkHeaders[selectedHunkIndex] ??
302
+ hunkHeaders[Math.max(0, hunkHeaders.length - 1)] ??
303
+ fileWrapper.querySelector<HTMLElement>('.d2h-file-header');
304
+ target?.scrollIntoView({ block: 'nearest' });
305
+ }, [selectedFileIndex, selectedHunkIndex, diffHtml]);
306
+
307
+ useEffect(() => {
308
+ const mediaQuery = window.matchMedia('(max-width: 680px)');
309
+ const update = () => {
310
+ setIsPhoneViewport(mediaQuery.matches);
311
+ };
312
+ update();
313
+ mediaQuery.addEventListener('change', update);
314
+ return () => {
315
+ mediaQuery.removeEventListener('change', update);
316
+ };
317
+ }, []);
318
+
319
+ useEffect(() => {
320
+ if (!inspectorOpen || !isPhoneViewport) {
321
+ return;
322
+ }
323
+ const previousOverflow = document.body.style.overflow;
324
+ document.body.style.overflow = 'hidden';
325
+ return () => {
326
+ document.body.style.overflow = previousOverflow;
327
+ };
328
+ }, [inspectorOpen, isPhoneViewport]);
329
+
330
+ useEffect(() => {
331
+ const params = new URLSearchParams();
332
+ params.set('cursor', streamCursorRef.current);
333
+ if (project.length > 0) {
334
+ params.set('project', project);
335
+ }
336
+ const eventSource = new EventSource(
337
+ `/api/features/${encodeURIComponent(featureId)}/log-stream?${params.toString()}`,
338
+ );
339
+
340
+ const onStreamEvent = (event: MessageEvent<string>) => {
341
+ let payload: LogStreamPayload;
342
+ try {
343
+ payload = JSON.parse(event.data) as LogStreamPayload;
344
+ } catch {
345
+ return;
346
+ }
347
+ if (payload.type === 'error') {
348
+ setStreamError(payload.message ?? 'Log stream warning');
349
+ return;
350
+ }
351
+ streamCursorRef.current = payload.cursor;
352
+ if (payload.entries.length === 0) {
353
+ return;
354
+ }
355
+ setStreamEntries((current) => appendCappedLogEntries(current, payload.entries));
356
+ };
357
+
358
+ eventSource.addEventListener('snapshot', onStreamEvent as EventListener);
359
+ eventSource.addEventListener('append', onStreamEvent as EventListener);
360
+ eventSource.addEventListener('error', () => {
361
+ setStreamError('Disconnected from event stream');
362
+ });
363
+
364
+ return () => {
365
+ eventSource.close();
366
+ };
367
+ }, [featureId, project]);
368
+
369
+ const selectedFilePath = filePaths[selectedFileIndex]?.path ?? '';
370
+ const selectedFileHunkCount = files[selectedFileIndex]?.hunks.length ?? 0;
371
+ const activeRoleMarker = useMemo(
372
+ () => resolveActiveRole(detail.feature.role_status),
373
+ [detail.feature.role_status],
374
+ );
375
+ const sessionRows = useMemo(
376
+ () => [
377
+ {
378
+ key: 'orchestrator',
379
+ label: 'Orchestrator',
380
+ sessionId: detail.feature.cluster?.orchestrator_session_id ?? 'unknown',
381
+ },
382
+ {
383
+ key: 'planner',
384
+ label: 'Planner',
385
+ sessionId: detail.feature.cluster?.planner_session_id ?? 'unknown',
386
+ },
387
+ {
388
+ key: 'builder',
389
+ label: 'Builder',
390
+ sessionId: detail.feature.cluster?.builder_session_id ?? 'unknown',
391
+ },
392
+ {
393
+ key: 'qa',
394
+ label: 'QA',
395
+ sessionId: detail.feature.cluster?.qa_session_id ?? 'unknown',
396
+ },
397
+ ],
398
+ [detail.feature.cluster],
399
+ );
400
+
401
+ const canSendConsoleMessage = useMemo(() => {
402
+ return isConsoleSendAllowed({
403
+ status: detail.feature.status,
404
+ cluster: detail.feature.cluster,
405
+ hasActiveRuntimeSession,
406
+ submitting,
407
+ message: consoleMessage,
408
+ nowMs: Date.now(),
409
+ lastMessageSentAtMs: lastMessageSentAt,
410
+ });
411
+ }, [
412
+ detail.feature.cluster,
413
+ detail.feature.status,
414
+ hasActiveRuntimeSession,
415
+ submitting,
416
+ consoleMessage,
417
+ lastMessageSentAt,
418
+ ]);
419
+
420
+ const runReviewAction = async (action: ReviewAction, payload: Record<string, unknown>) => {
421
+ if (submitting) {
422
+ return;
423
+ }
424
+ setSubmitting(true);
425
+ try {
426
+ const response = await callAction(action, featureId, project, payload);
427
+ if (!response.ok) {
428
+ addToast(response.error?.message ?? `${action} failed`, 'error');
429
+ return;
430
+ }
431
+ addToast(`${action} completed`, 'success');
432
+ await refreshStatus();
433
+ await refreshDetail(featureId);
434
+ } catch {
435
+ addToast(`${action} failed`, 'error');
436
+ } finally {
437
+ setSubmitting(false);
438
+ }
439
+ };
440
+
441
+ const openInspector = async (targetPath: string) => {
442
+ if (!targetPath) {
443
+ return;
444
+ }
445
+ setInspectorOpen(true);
446
+ setInspectorLoading(true);
447
+ setInspectorError(null);
448
+ try {
449
+ const params = new URLSearchParams();
450
+ params.set('path', targetPath);
451
+ if (project.length > 0) {
452
+ params.set('project', project);
453
+ }
454
+ const response = await fetch(
455
+ `/api/features/${encodeURIComponent(featureId)}/file-diff?${params.toString()}`,
456
+ );
457
+ const body = (await response.json()) as ApiEnvelope<FileDiffPayload>;
458
+ if (!body.ok || !body.data) {
459
+ setInspectorPayload(null);
460
+ setInspectorError(body.error?.message ?? 'Unable to load file diff');
461
+ return;
462
+ }
463
+ setInspectorPayload(body.data);
464
+ } catch {
465
+ setInspectorPayload(null);
466
+ setInspectorError('Unable to load file diff');
467
+ } finally {
468
+ setInspectorLoading(false);
469
+ }
470
+ };
471
+
472
+ const sendConsoleMessage = async () => {
473
+ const message = consoleMessage.trim();
474
+ if (message.length === 0) {
475
+ return;
476
+ }
477
+ const timestamp = new Date().toISOString();
478
+ setConsoleTranscript((current) => [
479
+ ...current,
480
+ {
481
+ id: `${timestamp}:${current.length}`,
482
+ timestamp,
483
+ text: message,
484
+ },
485
+ ]);
486
+ setConsoleMessage('');
487
+ setLastMessageSentAt(Date.now());
488
+ await runReviewAction('feature.send_message', { message });
489
+ };
490
+
491
+ const copySessionId = async (sessionId: string) => {
492
+ if (!hasActiveSession(sessionId)) {
493
+ addToast('No session id is available to copy', 'info');
494
+ return;
495
+ }
496
+ if (!navigator.clipboard?.writeText) {
497
+ addToast('Clipboard write is unavailable in this browser', 'error');
498
+ return;
499
+ }
500
+ try {
501
+ await navigator.clipboard.writeText(sessionId);
502
+ addToast('Session id copied', 'success');
503
+ } catch {
504
+ addToast('Failed to copy session id', 'error');
505
+ }
506
+ };
507
+
508
+ const fetchTimeline = async () => {
509
+ setTimelineLoading(true);
510
+ setTimelineError(null);
511
+ try {
512
+ const params = new URLSearchParams();
513
+ params.set('limit', '100');
514
+ if (timelineRole) {
515
+ params.set('role', timelineRole);
516
+ }
517
+ if (timelineValidity !== 'all') {
518
+ params.set('valid', timelineValidity);
519
+ }
520
+ if (project.length > 0) {
521
+ params.set('project', project);
522
+ }
523
+ const response = await fetch(
524
+ `/api/features/${encodeURIComponent(featureId)}/timeline?${params.toString()}`,
525
+ );
526
+ const body = (await response.json()) as ApiEnvelope<{ entries: WorkerEventEntry[] }>;
527
+ if (!body.ok || !body.data) {
528
+ setTimelineEntries([]);
529
+ setTimelineError(body.error?.message ?? 'Unable to load timeline');
530
+ return;
531
+ }
532
+ setTimelineEntries(body.data.entries);
533
+ } catch {
534
+ setTimelineEntries([]);
535
+ setTimelineError('Unable to load timeline');
536
+ } finally {
537
+ setTimelineLoading(false);
538
+ }
539
+ };
540
+
541
+ const fetchRawLogs = async () => {
542
+ setRawLogError(null);
543
+ try {
544
+ const params = new URLSearchParams();
545
+ params.set('limit', '100');
546
+ if (project.length > 0) {
547
+ params.set('project', project);
548
+ }
549
+ const response = await fetch(
550
+ `/api/features/${encodeURIComponent(featureId)}/raw-logs?${params.toString()}`,
551
+ );
552
+ const body = (await response.json()) as ApiEnvelope<{
553
+ enabled: boolean;
554
+ files: RawLogFileMeta[];
555
+ }>;
556
+ if (!body.ok || !body.data) {
557
+ setRawLogsEnabled(false);
558
+ setRawLogs([]);
559
+ setRawLogError(body.error?.message ?? 'Unable to load raw logs');
560
+ return;
561
+ }
562
+ setRawLogsEnabled(body.data.enabled);
563
+ setRawLogs(body.data.files);
564
+ if (body.data.files.length > 0 && !rawLogSelection) {
565
+ setRawLogSelection(body.data.files[0].filename);
566
+ }
567
+ } catch {
568
+ setRawLogsEnabled(false);
569
+ setRawLogs([]);
570
+ setRawLogError('Unable to load raw logs');
571
+ }
572
+ };
573
+
574
+ const fetchRawLogContent = async (filename: string) => {
575
+ setRawLogContent('');
576
+ setRawLogError(null);
577
+ try {
578
+ const suffix = project.length > 0 ? `?project=${encodeURIComponent(project)}` : '';
579
+ const response = await fetch(
580
+ `/api/features/${encodeURIComponent(featureId)}/raw-logs/${encodeURIComponent(filename)}${suffix}`,
581
+ );
582
+ const body = (await response.json()) as ApiEnvelope<{ filename: string; content: string }>;
583
+ if (!body.ok || !body.data) {
584
+ setRawLogError(body.error?.message ?? 'Unable to load raw log file');
585
+ return;
586
+ }
587
+ setRawLogContent(body.data.content);
588
+ } catch {
589
+ setRawLogError('Unable to load raw log file');
590
+ }
591
+ };
592
+
593
+ useEffect(() => {
594
+ void fetchTimeline();
595
+ }, [featureId, project, timelineRole, timelineValidity]);
596
+
597
+ useEffect(() => {
598
+ void fetchRawLogs();
599
+ }, [featureId, project]);
600
+
601
+ useEffect(() => {
602
+ setRawLogSelection(null);
603
+ setRawLogContent('');
604
+ }, [featureId]);
605
+
606
+ useEffect(() => {
607
+ if (!rawLogSelection) {
608
+ return;
609
+ }
610
+ void fetchRawLogContent(rawLogSelection);
611
+ }, [rawLogSelection]);
612
+
613
+ const onDiffKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
614
+ if (isEditableTarget(event.target)) {
615
+ return;
616
+ }
617
+ if (filePaths.length === 0) {
618
+ return;
619
+ }
620
+ if (event.key === 'j' || event.key === 'n') {
621
+ event.preventDefault();
622
+ setSelectedFileIndex((current) => Math.min(filePaths.length - 1, current + 1));
623
+ setSelectedHunkIndex(0);
624
+ return;
625
+ }
626
+ if (event.key === 'k' || event.key === 'p') {
627
+ event.preventDefault();
628
+ setSelectedFileIndex((current) => Math.max(0, current - 1));
629
+ setSelectedHunkIndex(0);
630
+ return;
631
+ }
632
+ if (event.key === 'o') {
633
+ event.preventDefault();
634
+ void openInspector(selectedFilePath);
635
+ return;
636
+ }
637
+ if (event.key === '[') {
638
+ event.preventDefault();
639
+ setSelectedHunkIndex((current) => stepHunkIndex(current, -1, selectedFileHunkCount));
640
+ return;
641
+ }
642
+ if (event.key === ']') {
643
+ event.preventDefault();
644
+ setSelectedHunkIndex((current) => stepHunkIndex(current, 1, selectedFileHunkCount));
645
+ }
646
+ };
647
+
648
+ const onConsoleKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
649
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
650
+ event.preventDefault();
651
+ void sendConsoleMessage();
652
+ }
653
+ };
654
+
655
+ const trustBadgeClassName = (value: TrustClass): string => {
656
+ if (value === 'Raw Provider Output') {
657
+ return `${styles.trustBadge} ${styles.trustBadgeRaw}`;
658
+ }
659
+ if (value === 'Derived') {
660
+ return `${styles.trustBadge} ${styles.trustBadgeDerived}`;
661
+ }
662
+ return `${styles.trustBadge} ${styles.trustBadgeStructured}`;
663
+ };
664
+
665
+ const renderTrustBadge = (value: TrustClass) => (
666
+ <span className={trustBadgeClassName(value)}>{value}</span>
667
+ );
668
+
669
+ return (
670
+ <div className={styles.reviewWorkspace}>
671
+ <section className={styles.reviewLeftPane}>
672
+ <div className={styles.section}>
673
+ <div className={styles.reviewHeaderRow}>
674
+ <div className={styles.reviewTitleWrap}>
675
+ <h3 className={styles.sectionTitle}>Diff Overview</h3>
676
+ {renderTrustBadge('Structured Artifact')}
677
+ </div>
678
+ <div className={styles.inlineActions}>
679
+ <button
680
+ type="button"
681
+ className={styles.buttonGhost}
682
+ onClick={() => setDiffShortcutsVisible((value) => !value)}
683
+ >
684
+ {diffShortcutsVisible ? 'Hide Hotkeys' : 'Show Hotkeys'}
685
+ </button>
686
+ <select
687
+ className={styles.actionSelect}
688
+ value={diffMode}
689
+ onChange={(event) => setDiffMode(event.target.value as DiffRenderMode)}
690
+ >
691
+ <option value="line-by-line">Line by line</option>
692
+ <option value="side-by-side">Side by side</option>
693
+ </select>
694
+ </div>
695
+ </div>
696
+ {diffShortcutsVisible ? (
697
+ <p className={styles.metaText}>
698
+ Hotkeys: j/n next file, k/p previous file, o open in inspector, [ / ] hunk
699
+ </p>
700
+ ) : null}
701
+ <p className={styles.metaText}>
702
+ {diffStats.filesChanged} files changed • +{diffStats.additions} / -{diffStats.deletions}
703
+ </p>
704
+ {filePaths.length === 0 ? (
705
+ <p className={styles.metaText}>No changes in this feature worktree.</p>
706
+ ) : (
707
+ <>
708
+ <div className={styles.reviewFileSelectorRow}>
709
+ <select
710
+ className={styles.actionSelect}
711
+ value={selectedFilePath}
712
+ onChange={(event) => {
713
+ const index = filePaths.findIndex((file) => file.path === event.target.value);
714
+ if (index >= 0) {
715
+ setSelectedFileIndex(index);
716
+ setSelectedHunkIndex(0);
717
+ }
718
+ }}
719
+ >
720
+ {filePaths.map((file) => (
721
+ <option key={file.path} value={file.path}>
722
+ {file.path} ({file.hunkCount} hunks)
723
+ </option>
724
+ ))}
725
+ </select>
726
+ <button
727
+ type="button"
728
+ className={styles.button}
729
+ onClick={() => void openInspector(selectedFilePath)}
730
+ >
731
+ Open In Inspector
732
+ </button>
733
+ </div>
734
+ <p className={styles.metaText}>
735
+ File {selectedFileIndex + 1}/{filePaths.length} • Hunk{' '}
736
+ {Math.min(selectedHunkIndex + 1, Math.max(1, selectedFileHunkCount))}
737
+ </p>
738
+ <div
739
+ ref={diffRegionRef}
740
+ className={styles.reviewDiffRegion}
741
+ role="region"
742
+ tabIndex={0}
743
+ aria-label="Diff overview"
744
+ onKeyDown={onDiffKeyDown}
745
+ >
746
+ <div
747
+ ref={diffContentRef}
748
+ className={styles.reviewDiffHtml}
749
+ dangerouslySetInnerHTML={{ __html: diffHtml }}
750
+ />
751
+ </div>
752
+ </>
753
+ )}
754
+ </div>
755
+
756
+ {inspectorOpen && !isPhoneViewport ? (
757
+ <div className={`${styles.section} ${styles.inspectorPanel}`}>
758
+ <div className={styles.reviewHeaderRow}>
759
+ <div className={styles.reviewTitleWrap}>
760
+ <h3 className={styles.sectionTitle}>File Inspector</h3>
761
+ {renderTrustBadge('Structured Artifact')}
762
+ </div>
763
+ <button
764
+ type="button"
765
+ className={styles.buttonGhost}
766
+ onClick={() => setInspectorOpen(false)}
767
+ >
768
+ Close
769
+ </button>
770
+ </div>
771
+ {inspectorLoading ? <p className={styles.metaText}>Loading file inspector...</p> : null}
772
+ {inspectorError ? <p className={styles.metaText}>{inspectorError}</p> : null}
773
+ {!inspectorLoading && !inspectorError && inspectorPayload ? (
774
+ <div className={styles.reviewMonacoWrap}>
775
+ <MonacoDiffEditor
776
+ height="420px"
777
+ language={inspectorPayload.language}
778
+ original={inspectorPayload.original}
779
+ modified={inspectorPayload.modified}
780
+ options={{
781
+ readOnly: true,
782
+ renderSideBySide: true,
783
+ minimap: { enabled: false },
784
+ scrollBeyondLastLine: false,
785
+ }}
786
+ />
787
+ </div>
788
+ ) : null}
789
+ </div>
790
+ ) : null}
791
+ </section>
792
+
793
+ <aside className={styles.reviewRightPane}>
794
+ <div className={`${styles.section} ${styles.reviewActionSection}`}>
795
+ <div className={styles.reviewTitleWrap}>
796
+ <h3 className={styles.sectionTitle}>Review Actions</h3>
797
+ {renderTrustBadge('Derived')}
798
+ </div>
799
+ <label className={styles.label} htmlFor="review-message">
800
+ Review message
801
+ </label>
802
+ <textarea
803
+ id="review-message"
804
+ className={styles.textarea}
805
+ rows={3}
806
+ value={reviewMessage}
807
+ onChange={(event) => setReviewMessage(event.target.value)}
808
+ />
809
+ <label className={styles.label} htmlFor="approval-token">
810
+ Approval token
811
+ </label>
812
+ <input
813
+ id="approval-token"
814
+ className={styles.input}
815
+ value={approvalToken}
816
+ onChange={(event) => setApprovalToken(event.target.value)}
817
+ />
818
+ <label className={styles.label} htmlFor="merge-strategy">
819
+ Merge strategy
820
+ </label>
821
+ <select
822
+ id="merge-strategy"
823
+ className={styles.actionSelect}
824
+ value={mergeStrategy}
825
+ onChange={(event) =>
826
+ setMergeStrategy(event.target.value as 'merge_commit' | 'squash' | 'rebase')
827
+ }
828
+ >
829
+ <option value="merge_commit">Merge Commit</option>
830
+ <option value="squash">Squash</option>
831
+ <option value="rebase">Rebase</option>
832
+ </select>
833
+ <div className={styles.actionGrid}>
834
+ <button
835
+ type="button"
836
+ className={`${styles.button} ${styles.buttonSuccess}`}
837
+ disabled={submitting || approvalToken.trim().length === 0}
838
+ onClick={() =>
839
+ void runReviewAction('review.approve', {
840
+ approval_token: approvalToken.trim(),
841
+ merge_strategy: mergeStrategy,
842
+ message: reviewMessage.trim(),
843
+ })
844
+ }
845
+ >
846
+ Approve
847
+ </button>
848
+ <button
849
+ type="button"
850
+ className={`${styles.button} ${styles.buttonDanger}`}
851
+ disabled={submitting || reviewMessage.trim().length === 0}
852
+ onClick={() => void runReviewAction('review.deny', { reason: reviewMessage.trim() })}
853
+ >
854
+ Deny
855
+ </button>
856
+ <button
857
+ type="button"
858
+ className={styles.button}
859
+ disabled={submitting}
860
+ onClick={() =>
861
+ void runReviewAction('review.request_changes', { message: reviewMessage.trim() })
862
+ }
863
+ >
864
+ Request Changes
865
+ </button>
866
+ </div>
867
+ </div>
868
+
869
+ <div className={styles.section}>
870
+ <div className={styles.reviewHeaderRow}>
871
+ <div className={styles.reviewTitleWrap}>
872
+ <h3 className={styles.sectionTitle}>Agent Log Stream</h3>
873
+ {renderTrustBadge('Structured Artifact')}
874
+ </div>
875
+ <button
876
+ type="button"
877
+ className={styles.buttonGhost}
878
+ onClick={() => setStreamFollow((value) => !value)}
879
+ >
880
+ {streamFollow ? 'Pause Follow' : 'Resume Follow'}
881
+ </button>
882
+ </div>
883
+ {streamError ? <p className={styles.metaText}>{streamError}</p> : null}
884
+ <div ref={logListRef} className={styles.reviewLogList}>
885
+ {streamEntries.map((entry) => (
886
+ <div key={`${entry.id}:${entry.timestamp}`} className={styles.reviewLogEntry}>
887
+ <span className={styles.reviewLogStamp}>{formatTimestamp(entry.timestamp)}</span>
888
+ <span className={styles.reviewRoleBadge}>{entry.role}</span>
889
+ <strong>{entry.actor}</strong>
890
+ <span>{entry.text}</span>
891
+ </div>
892
+ ))}
893
+ </div>
894
+ </div>
895
+
896
+ <div className={styles.section}>
897
+ <div className={styles.reviewTitleWrap}>
898
+ <h3 className={styles.sectionTitle}>Agent Console</h3>
899
+ {renderTrustBadge('Derived')}
900
+ </div>
901
+ <textarea
902
+ className={styles.textarea}
903
+ rows={3}
904
+ value={consoleMessage}
905
+ placeholder="Send a directed message to the active agent session"
906
+ onChange={(event) => setConsoleMessage(event.target.value)}
907
+ onKeyDown={onConsoleKeyDown}
908
+ />
909
+ <p className={styles.metaText}>
910
+ {hasActiveRuntimeSession
911
+ ? 'Cmd/Ctrl + Enter to send. Minimum 1s between messages.'
912
+ : 'No active runtime session is available for this feature.'}
913
+ </p>
914
+ <button
915
+ type="button"
916
+ className={styles.button}
917
+ disabled={!canSendConsoleMessage}
918
+ onClick={() => void sendConsoleMessage()}
919
+ >
920
+ Send Message
921
+ </button>
922
+ <div className={styles.reviewConsoleTranscript}>
923
+ {consoleTranscript.map((entry) => (
924
+ <div key={entry.id} className={styles.reviewTranscriptEntry}>
925
+ <strong>You</strong>{' '}
926
+ <span className={styles.metaText}>{formatTimestamp(entry.timestamp)}</span>
927
+ <p>{entry.text}</p>
928
+ </div>
929
+ ))}
930
+ </div>
931
+ </div>
932
+
933
+ <details className={styles.section}>
934
+ <summary className={styles.sectionSummary}>Agent Session Status</summary>
935
+ <p className={styles.trustLabelRow}>{renderTrustBadge('Structured Artifact')}</p>
936
+ <p className={styles.metaText}>
937
+ Active role:{' '}
938
+ {activeRoleMarker.role && activeRoleMarker.status
939
+ ? `${activeRoleMarker.role} (${activeRoleMarker.status})`
940
+ : 'unknown'}
941
+ </p>
942
+ <div className={styles.reviewSessionRows}>
943
+ {sessionRows.map((row) => {
944
+ const isActiveRole = row.key === activeRoleMarker.role;
945
+ const roleStatus =
946
+ row.key === 'planner' || row.key === 'builder' || row.key === 'qa'
947
+ ? (detail.feature.role_status?.[row.key] ?? 'unknown')
948
+ : 'n/a';
949
+ return (
950
+ <div
951
+ key={row.key}
952
+ className={`${styles.reviewSessionRow}${isActiveRole ? ` ${styles.reviewSessionRowActive}` : ''}`}
953
+ >
954
+ <div className={styles.reviewSessionIdentity}>
955
+ <span>{row.label}</span>
956
+ <span className={styles.reviewSessionState}>{roleStatus}</span>
957
+ {isActiveRole ? (
958
+ <span className={styles.reviewSessionMarker}>active</span>
959
+ ) : null}
960
+ </div>
961
+ <div className={styles.reviewSessionValue}>
962
+ <span>{truncateSessionId(row.sessionId)}</span>
963
+ <button
964
+ type="button"
965
+ className={styles.buttonGhost}
966
+ disabled={!hasActiveSession(row.sessionId)}
967
+ onClick={() => {
968
+ void copySessionId(row.sessionId);
969
+ }}
970
+ >
971
+ Copy
972
+ </button>
973
+ </div>
974
+ </div>
975
+ );
976
+ })}
977
+ </div>
978
+ </details>
979
+
980
+ <details
981
+ className={styles.section}
982
+ onToggle={(event) => {
983
+ if ((event.currentTarget as HTMLDetailsElement).open) {
984
+ void fetchTimeline();
985
+ }
986
+ }}
987
+ >
988
+ <summary className={styles.sectionSummary}>Runtime Inspector</summary>
989
+ <p className={styles.trustLabelRow}>{renderTrustBadge('Structured Artifact')}</p>
990
+ <div className={styles.runtimeInspectorTabs}>
991
+ <button
992
+ type="button"
993
+ className={`${styles.runtimeInspectorTab}${runtimeInspectorTab === 'activity' ? ` ${styles.runtimeInspectorTabActive}` : ''}`}
994
+ onClick={() => setRuntimeInspectorTab('activity')}
995
+ >
996
+ Activity
997
+ </button>
998
+ <button
999
+ type="button"
1000
+ className={`${styles.runtimeInspectorTab}${runtimeInspectorTab === 'checkpoints' ? ` ${styles.runtimeInspectorTabActive}` : ''}`}
1001
+ onClick={() => setRuntimeInspectorTab('checkpoints')}
1002
+ >
1003
+ Checkpoints
1004
+ </button>
1005
+ </div>
1006
+ {runtimeInspectorTab === 'activity' ? (
1007
+ <>
1008
+ <div className={styles.reviewTimelineFilters}>
1009
+ <select
1010
+ className={styles.actionSelect}
1011
+ value={timelineRole}
1012
+ onChange={(event) => setTimelineRole(event.target.value)}
1013
+ >
1014
+ <option value="">All roles</option>
1015
+ <option value="planner">Planner</option>
1016
+ <option value="builder">Builder</option>
1017
+ <option value="qa">QA</option>
1018
+ </select>
1019
+ <select
1020
+ className={styles.actionSelect}
1021
+ value={timelineValidity}
1022
+ onChange={(event) => setTimelineValidity(event.target.value as TimelineValidity)}
1023
+ >
1024
+ <option value="all">All validity</option>
1025
+ <option value="valid">Valid only</option>
1026
+ <option value="invalid">Invalid only</option>
1027
+ </select>
1028
+ </div>
1029
+ {timelineLoading ? <p className={styles.metaText}>Loading timeline...</p> : null}
1030
+ {timelineError ? <p className={styles.metaText}>{timelineError}</p> : null}
1031
+ {!timelineLoading && !timelineError ? (
1032
+ <div className={styles.reviewTimelineList}>
1033
+ {timelineEntries.map((entry, index) => (
1034
+ <div key={`${entry.ts}:${index}`} className={styles.reviewTimelineEntry}>
1035
+ <span>{formatTimestamp(entry.ts)}</span>
1036
+ <span>{entry.role}</span>
1037
+ <span>{entry.event_type ?? 'event'}</span>
1038
+ <span>{entry.valid ? 'valid' : 'invalid'}</span>
1039
+ </div>
1040
+ ))}
1041
+ </div>
1042
+ ) : null}
1043
+ </>
1044
+ ) : (
1045
+ <>
1046
+ <div className={styles.badgeRow}>
1047
+ <span className={`${styles.badge} ${styles.badgeNeutral}`}>
1048
+ mode: {detail.execution_mode ?? 'deterministic'}
1049
+ </span>
1050
+ <span className={`${styles.badge} ${styles.badgeNeutral}`}>
1051
+ {detail.checkpoints?.length ?? 0} checkpoints
1052
+ </span>
1053
+ </div>
1054
+ {detail.checkpoints && detail.checkpoints.length > 0 ? (
1055
+ <div className={styles.reviewTimelineList}>
1056
+ {detail.checkpoints.map((checkpoint) => (
1057
+ <div
1058
+ key={`${checkpoint.checkpoint_id}:${checkpoint.timestamp}`}
1059
+ className={styles.reviewTimelineEntry}
1060
+ >
1061
+ <div className={styles.badgeRow}>
1062
+ <span className={checkpointStatusBadge(checkpoint, styles)}>
1063
+ {checkpoint.validation_status}
1064
+ </span>
1065
+ {checkpoint.severity ? (
1066
+ <span className={checkpointSeverityBadge(checkpoint.severity, styles)}>
1067
+ {checkpoint.severity}
1068
+ </span>
1069
+ ) : null}
1070
+ </div>
1071
+ <span>{formatCheckpointTimestamp(checkpoint.timestamp)}</span>
1072
+ <span>{checkpoint.files_changed.length} files</span>
1073
+ <span>{checkpoint.checkpoint_id}</span>
1074
+ {checkpoint.violations.length > 0 ? (
1075
+ <span>{checkpoint.violations[0]}</span>
1076
+ ) : (
1077
+ <span>no violations</span>
1078
+ )}
1079
+ </div>
1080
+ ))}
1081
+ </div>
1082
+ ) : (
1083
+ <p className={styles.metaText}>No checkpoints recorded for this feature yet.</p>
1084
+ )}
1085
+ </>
1086
+ )}
1087
+ </details>
1088
+
1089
+ <details
1090
+ className={styles.section}
1091
+ onToggle={(event) => {
1092
+ if ((event.currentTarget as HTMLDetailsElement).open) {
1093
+ void fetchRawLogs();
1094
+ }
1095
+ }}
1096
+ >
1097
+ <summary className={styles.sectionSummary}>Raw Agent Output</summary>
1098
+ <p className={styles.trustLabelRow}>{renderTrustBadge('Raw Provider Output')}</p>
1099
+ <p className={styles.banner}>Raw output may contain sensitive data.</p>
1100
+ {rawLogsEnabled === false ? (
1101
+ <p className={styles.metaText}>Raw output is disabled by policy.</p>
1102
+ ) : null}
1103
+ {rawLogError ? <p className={styles.metaText}>{rawLogError}</p> : null}
1104
+ {rawLogsEnabled ? (
1105
+ <>
1106
+ <select
1107
+ className={styles.actionSelect}
1108
+ value={rawLogSelection ?? ''}
1109
+ onChange={(event) => setRawLogSelection(event.target.value)}
1110
+ >
1111
+ {rawLogs.map((file) => (
1112
+ <option key={file.filename} value={file.filename}>
1113
+ {file.filename} ({Math.round(file.size_bytes / 1024)} KB)
1114
+ </option>
1115
+ ))}
1116
+ </select>
1117
+ <pre className={styles.preContent}>{rawLogContent}</pre>
1118
+ </>
1119
+ ) : null}
1120
+ </details>
1121
+ </aside>
1122
+
1123
+ {inspectorOpen && isPhoneViewport ? (
1124
+ <div className={styles.mobileInspectorOverlay} role="dialog" aria-modal="true">
1125
+ <div className={styles.mobileInspectorPanel}>
1126
+ <div className={styles.reviewHeaderRow}>
1127
+ <div className={styles.reviewTitleWrap}>
1128
+ <h3 className={styles.sectionTitle}>File Inspector</h3>
1129
+ {renderTrustBadge('Structured Artifact')}
1130
+ </div>
1131
+ <button
1132
+ type="button"
1133
+ className={styles.buttonGhost}
1134
+ onClick={() => setInspectorOpen(false)}
1135
+ >
1136
+ Close
1137
+ </button>
1138
+ </div>
1139
+ {inspectorLoading ? <p className={styles.metaText}>Loading file inspector...</p> : null}
1140
+ {inspectorError ? <p className={styles.metaText}>{inspectorError}</p> : null}
1141
+ {!inspectorLoading && !inspectorError && inspectorPayload ? (
1142
+ <div className={styles.reviewMonacoWrap}>
1143
+ <MonacoDiffEditor
1144
+ height="calc(100dvh - 9.5rem)"
1145
+ language={inspectorPayload.language}
1146
+ original={inspectorPayload.original}
1147
+ modified={inspectorPayload.modified}
1148
+ options={{
1149
+ readOnly: true,
1150
+ renderSideBySide: true,
1151
+ minimap: { enabled: false },
1152
+ scrollBeyondLastLine: false,
1153
+ }}
1154
+ />
1155
+ </div>
1156
+ ) : null}
1157
+ </div>
1158
+ </div>
1159
+ ) : null}
1160
+ </div>
1161
+ );
1162
+ }