ralph-review 0.2.4 → 0.2.6
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.
- package/package.json +1 -1
- package/src/lib/review-workflow/review/run-review-session.ts +14 -0
- package/src/lib/stop-session.ts +63 -1
- package/src/lib/tui/dashboard/Dashboard.tsx +56 -10
- package/src/lib/tui/dashboard/dashboard-group-selection.ts +34 -0
- package/src/lib/tui/dashboard/dashboard-keyboard.ts +17 -1
- package/src/lib/tui/dashboard/use-dashboard-run-control.ts +11 -3
- package/src/lib/tui/dashboard/use-dashboard-stop-control.ts +17 -10
- package/src/lib/tui/sessions/sidebar/SessionGroup.tsx +10 -10
- package/src/lib/tui/sessions/sidebar/SessionItem.tsx +3 -6
- package/src/lib/tui/sessions/sidebar/SessionSidebar.tsx +4 -3
- package/src/lib/tui/shared/types.ts +10 -0
- package/src/lib/tui/workspace/Workspace.tsx +3 -3
- package/src/lib/tui/workspace/use-workspace-state.ts +63 -16
- package/src/lib/tui/workspace/workspace-types.ts +1 -1
package/package.json
CHANGED
|
@@ -325,6 +325,20 @@ export async function runReviewSession(
|
|
|
325
325
|
if (entry.type === "review_iteration") {
|
|
326
326
|
latestPersistedFindings = [...latestPersistedFindings, ...entry.findings];
|
|
327
327
|
completedReviewIterations = entry.iteration;
|
|
328
|
+
|
|
329
|
+
if (worktree && latestPersistedFindings.length > 0) {
|
|
330
|
+
await deps.saveFindingsArtifact(
|
|
331
|
+
CONFIG_DIR,
|
|
332
|
+
createFindingsArtifact(
|
|
333
|
+
sessionId,
|
|
334
|
+
projectPath,
|
|
335
|
+
sessionPath,
|
|
336
|
+
worktree,
|
|
337
|
+
latestPersistedFindings
|
|
338
|
+
)
|
|
339
|
+
);
|
|
340
|
+
shouldDeleteSessionRefs = false;
|
|
341
|
+
}
|
|
328
342
|
}
|
|
329
343
|
};
|
|
330
344
|
|
package/src/lib/stop-session.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { discardSessionWorktree, type GitSessionWorktree } from "@/lib/git";
|
|
2
|
-
import { deleteSessionFiles, readLog } from "@/lib/logger";
|
|
2
|
+
import { appendLog, deleteSessionFiles, readLog } from "@/lib/logger";
|
|
3
3
|
import {
|
|
4
4
|
type ActiveSession,
|
|
5
5
|
readSessionState,
|
|
@@ -27,6 +27,7 @@ interface WaitForGracefulStopDeps {
|
|
|
27
27
|
|
|
28
28
|
interface StopActiveSessionDeps {
|
|
29
29
|
readLog: typeof readLog;
|
|
30
|
+
appendLog: typeof appendLog;
|
|
30
31
|
deleteSessionFiles: typeof deleteSessionFiles;
|
|
31
32
|
updateSessionState: typeof updateSessionState;
|
|
32
33
|
sendInterrupt: typeof sendInterrupt;
|
|
@@ -41,6 +42,7 @@ interface StopActiveSessionDeps {
|
|
|
41
42
|
|
|
42
43
|
const DEFAULT_STOP_ACTIVE_SESSION_DEPS: StopActiveSessionDeps = {
|
|
43
44
|
readLog,
|
|
45
|
+
appendLog,
|
|
44
46
|
deleteSessionFiles,
|
|
45
47
|
updateSessionState,
|
|
46
48
|
sendInterrupt,
|
|
@@ -137,6 +139,7 @@ interface SessionIterationState {
|
|
|
137
139
|
hasRecordedIteration: boolean;
|
|
138
140
|
hasRecordedReviewProgress: boolean;
|
|
139
141
|
hasSuccessfulReviewIteration: boolean;
|
|
142
|
+
entries: LogEntry[];
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
async function resolveSessionIterationState(
|
|
@@ -153,12 +156,58 @@ async function resolveSessionIterationState(
|
|
|
153
156
|
hasRecordedIteration: hasRecordedIteration(entries),
|
|
154
157
|
hasRecordedReviewProgress: hasRecordedReviewProgress(entries),
|
|
155
158
|
hasSuccessfulReviewIteration: hasSuccessfulReviewIteration(entries),
|
|
159
|
+
entries,
|
|
156
160
|
};
|
|
157
161
|
} catch {
|
|
158
162
|
return null;
|
|
159
163
|
}
|
|
160
164
|
}
|
|
161
165
|
|
|
166
|
+
function getLatestLifecycleEntry(entries: LogEntry[]): LogEntry | undefined {
|
|
167
|
+
return [...entries].reverse().find((entry) => entry.type !== "handoff");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getLatestReviewIteration(entries: LogEntry[]) {
|
|
171
|
+
return entries.filter((entry) => entry.type === "review_iteration").at(-1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getAccumulatedReviewFindings(entries: LogEntry[]) {
|
|
175
|
+
return entries.flatMap((entry) => (entry.type === "review_iteration" ? entry.findings : []));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function terminalizeForceStoppedReviewSession(
|
|
179
|
+
session: ActiveSession,
|
|
180
|
+
iterationState: SessionIterationState | null,
|
|
181
|
+
deps: StopActiveSessionDeps
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
if (!session.sessionPath || !iterationState?.hasRecordedReviewProgress) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (getLatestLifecycleEntry(iterationState.entries)?.type === "session_end") {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const latestReviewIteration = getLatestReviewIteration(iterationState.entries);
|
|
192
|
+
if (!latestReviewIteration) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await deps.appendLog(session.sessionPath, {
|
|
197
|
+
type: "session_end",
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
status: "interrupted",
|
|
200
|
+
reason: "Review stopped by user.",
|
|
201
|
+
iterations: latestReviewIteration.iteration,
|
|
202
|
+
phase: "review",
|
|
203
|
+
sessionStatus: "interrupted",
|
|
204
|
+
reviewOutcome:
|
|
205
|
+
getAccumulatedReviewFindings(iterationState.entries).length > 0
|
|
206
|
+
? "findings-pending"
|
|
207
|
+
: "incomplete",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
162
211
|
function createCleanupWorktree(
|
|
163
212
|
session: ActiveSession,
|
|
164
213
|
sourceRepoPath: string
|
|
@@ -239,6 +288,15 @@ export async function stopActiveSession(
|
|
|
239
288
|
}
|
|
240
289
|
|
|
241
290
|
const finalIterationState = await resolveSessionIterationState(session, stopDeps);
|
|
291
|
+
let terminalizeSessionError: unknown;
|
|
292
|
+
if (!stoppedGracefully) {
|
|
293
|
+
try {
|
|
294
|
+
await terminalizeForceStoppedReviewSession(session, finalIterationState, stopDeps);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
terminalizeSessionError = error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
242
300
|
if (finalIterationState?.hasSuccessfulReviewIteration === false) {
|
|
243
301
|
cleanupUnpromotedSessionWorktree(session, stopDeps);
|
|
244
302
|
}
|
|
@@ -256,6 +314,10 @@ export async function stopActiveSession(
|
|
|
256
314
|
expectedSessionId: session.sessionId,
|
|
257
315
|
});
|
|
258
316
|
|
|
317
|
+
if (terminalizeSessionError) {
|
|
318
|
+
throw terminalizeSessionError;
|
|
319
|
+
}
|
|
320
|
+
|
|
259
321
|
if (deleteSessionFilesError) {
|
|
260
322
|
throw deleteSessionFilesError;
|
|
261
323
|
}
|
|
@@ -11,15 +11,25 @@ import type { FocusedPane } from "@/lib/tui/workspace/workspace-types";
|
|
|
11
11
|
import { DashboardOverlays } from "./DashboardOverlays";
|
|
12
12
|
import { getPendingFixTarget } from "./dashboard-fix-state";
|
|
13
13
|
import { cycleDashboardFocus, cycleDashboardFocusReverse } from "./dashboard-focus";
|
|
14
|
+
import { resolveSelectedGroupPath, selectAdjacentGroupPath } from "./dashboard-group-selection";
|
|
14
15
|
import { isDashboardOverlayBlockingFocus } from "./dashboard-overlay-state";
|
|
15
16
|
import { Header } from "./Header";
|
|
16
17
|
import { StatusBar } from "./StatusBar";
|
|
17
18
|
import { useDashboardRunControl } from "./use-dashboard-run-control";
|
|
18
19
|
import { useDashboardStopControl } from "./use-dashboard-stop-control";
|
|
19
20
|
|
|
20
|
-
export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: DashboardProps) {
|
|
21
|
+
export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }: DashboardProps) {
|
|
21
22
|
const renderer = useRenderer();
|
|
22
|
-
const
|
|
23
|
+
const resolvedUseWorkspaceState = deps?.useWorkspaceState ?? useWorkspaceState;
|
|
24
|
+
const ResolvedDashboardOverlays = deps?.DashboardOverlays ?? DashboardOverlays;
|
|
25
|
+
const [selectedGroupPath, setSelectedGroupPath] = useState<string>(projectPath);
|
|
26
|
+
const state = resolvedUseWorkspaceState(
|
|
27
|
+
projectPath,
|
|
28
|
+
branch,
|
|
29
|
+
refreshInterval,
|
|
30
|
+
undefined,
|
|
31
|
+
selectedGroupPath
|
|
32
|
+
);
|
|
23
33
|
const {
|
|
24
34
|
runError,
|
|
25
35
|
startupMode,
|
|
@@ -29,7 +39,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
29
39
|
spawnRunProcess,
|
|
30
40
|
spawnFixProcess,
|
|
31
41
|
isStartupSpawning,
|
|
32
|
-
} = useDashboardRunControl(projectPath);
|
|
42
|
+
} = useDashboardRunControl(projectPath, { spawn: deps?.spawn });
|
|
33
43
|
const [focusedPane, setFocusedPane] = useState<FocusedPane>("detail");
|
|
34
44
|
const [outputVisible, setOutputVisible] = useState(false);
|
|
35
45
|
const [showHelp, setShowHelp] = useState(false);
|
|
@@ -37,11 +47,14 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
37
47
|
const [showSession, setShowSession] = useState(false);
|
|
38
48
|
const [showReviewModeOverlay, setShowReviewModeOverlay] = useState(false);
|
|
39
49
|
const [showStopPicker, setShowStopPicker] = useState(false);
|
|
40
|
-
const { isStoppingRun, stopSelectedSession } = useDashboardStopControl(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
const { isStoppingRun, stopSelectedSession } = useDashboardStopControl(
|
|
51
|
+
{
|
|
52
|
+
currentSession: state.currentSession,
|
|
53
|
+
setShowStopPicker,
|
|
54
|
+
onError: setRunError,
|
|
55
|
+
},
|
|
56
|
+
{ stopActiveSession: deps?.stopActiveSession }
|
|
57
|
+
);
|
|
45
58
|
|
|
46
59
|
const projectName = basename(projectPath);
|
|
47
60
|
const isExitingRef = useRef(false);
|
|
@@ -80,6 +93,15 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
80
93
|
setShowFixFindings(false);
|
|
81
94
|
}
|
|
82
95
|
|
|
96
|
+
const resolvedSelectedGroupPath = resolveSelectedGroupPath(
|
|
97
|
+
state.sessionGroups,
|
|
98
|
+
selectedGroupPath,
|
|
99
|
+
projectPath
|
|
100
|
+
);
|
|
101
|
+
if (resolvedSelectedGroupPath !== selectedGroupPath) {
|
|
102
|
+
setSelectedGroupPath(resolvedSelectedGroupPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
const shutdown = useCallback(
|
|
84
106
|
async (after?: () => Promise<void>, exitCode: number = 0) => {
|
|
85
107
|
if (isExitingRef.current) {
|
|
@@ -119,6 +141,18 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
119
141
|
setFocusedPane((current) => cycleDashboardFocusReverse(current, outputVisible));
|
|
120
142
|
}, [outputVisible]);
|
|
121
143
|
|
|
144
|
+
const selectPrevGroup = useCallback(() => {
|
|
145
|
+
setSelectedGroupPath((current) =>
|
|
146
|
+
selectAdjacentGroupPath(state.sessionGroups, current, "prev")
|
|
147
|
+
);
|
|
148
|
+
}, [state.sessionGroups]);
|
|
149
|
+
|
|
150
|
+
const selectNextGroup = useCallback(() => {
|
|
151
|
+
setSelectedGroupPath((current) =>
|
|
152
|
+
selectAdjacentGroupPath(state.sessionGroups, current, "next")
|
|
153
|
+
);
|
|
154
|
+
}, [state.sessionGroups]);
|
|
155
|
+
|
|
122
156
|
const handleKeyboard = useCallback(
|
|
123
157
|
(key: { name: string }) => {
|
|
124
158
|
const action = resolveDashboardKeyAction({
|
|
@@ -132,6 +166,8 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
132
166
|
hasCurrentSession: Boolean(state.currentSession),
|
|
133
167
|
canFixPendingSession,
|
|
134
168
|
isRunSpawning: isStartupSpawning(),
|
|
169
|
+
sidebarFocused: focusedPane === "sidebar",
|
|
170
|
+
sessionGroupCount: state.sessionGroups.length,
|
|
135
171
|
});
|
|
136
172
|
|
|
137
173
|
switch (action) {
|
|
@@ -182,6 +218,12 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
182
218
|
clearRunError();
|
|
183
219
|
setShowReviewModeOverlay(true);
|
|
184
220
|
return;
|
|
221
|
+
case "select-prev-group":
|
|
222
|
+
selectPrevGroup();
|
|
223
|
+
return;
|
|
224
|
+
case "select-next-group":
|
|
225
|
+
selectNextGroup();
|
|
226
|
+
return;
|
|
185
227
|
}
|
|
186
228
|
},
|
|
187
229
|
[
|
|
@@ -189,7 +231,10 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
189
231
|
clearRunError,
|
|
190
232
|
cycleFocus,
|
|
191
233
|
cycleFocusReverse,
|
|
234
|
+
focusedPane,
|
|
192
235
|
isStartupSpawning,
|
|
236
|
+
selectNextGroup,
|
|
237
|
+
selectPrevGroup,
|
|
193
238
|
showFixFindings,
|
|
194
239
|
showHelp,
|
|
195
240
|
showReviewModeOverlay,
|
|
@@ -198,6 +243,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
198
243
|
shutdown,
|
|
199
244
|
state.allSessions,
|
|
200
245
|
state.currentSession,
|
|
246
|
+
state.sessionGroups.length,
|
|
201
247
|
stopSelectedSession,
|
|
202
248
|
]
|
|
203
249
|
);
|
|
@@ -233,7 +279,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
233
279
|
) : (
|
|
234
280
|
<Workspace
|
|
235
281
|
sessionGroups={state.sessionGroups}
|
|
236
|
-
|
|
282
|
+
selectedGroupPath={selectedGroupPath}
|
|
237
283
|
session={state.currentSession}
|
|
238
284
|
fixes={state.fixes}
|
|
239
285
|
skipped={state.skipped}
|
|
@@ -271,7 +317,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000 }: Dashb
|
|
|
271
317
|
liveRefreshError={state.liveRefreshError}
|
|
272
318
|
configWarning={state.configWarning}
|
|
273
319
|
/>
|
|
274
|
-
<
|
|
320
|
+
<ResolvedDashboardOverlays
|
|
275
321
|
showHelp={showHelp}
|
|
276
322
|
showRunOverlay={showRunOverlay}
|
|
277
323
|
showFixFindings={showFixFindings}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SessionGroupData } from "@/lib/tui/workspace/workspace-types";
|
|
2
|
+
|
|
3
|
+
export function selectAdjacentGroupPath(
|
|
4
|
+
groups: SessionGroupData[],
|
|
5
|
+
currentPath: string,
|
|
6
|
+
direction: "prev" | "next"
|
|
7
|
+
): string {
|
|
8
|
+
if (groups.length === 0) {
|
|
9
|
+
return currentPath;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const index = groups.findIndex((group) => group.projectPath === currentPath);
|
|
13
|
+
if (index < 0) {
|
|
14
|
+
return groups[0]?.projectPath ?? currentPath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const delta = direction === "prev" ? -1 : 1;
|
|
18
|
+
const nextIndex = Math.min(groups.length - 1, Math.max(0, index + delta));
|
|
19
|
+
return groups[nextIndex]?.projectPath ?? currentPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveSelectedGroupPath(
|
|
23
|
+
groups: SessionGroupData[],
|
|
24
|
+
preferredPath: string,
|
|
25
|
+
fallbackPath: string
|
|
26
|
+
): string {
|
|
27
|
+
if (groups.some((group) => group.projectPath === preferredPath)) {
|
|
28
|
+
return preferredPath;
|
|
29
|
+
}
|
|
30
|
+
if (groups.some((group) => group.projectPath === fallbackPath)) {
|
|
31
|
+
return fallbackPath;
|
|
32
|
+
}
|
|
33
|
+
return groups[0]?.projectPath ?? fallbackPath;
|
|
34
|
+
}
|
|
@@ -17,7 +17,9 @@ export type DashboardKeyAction =
|
|
|
17
17
|
| "open-session"
|
|
18
18
|
| "stop-single-session"
|
|
19
19
|
| "open-stop-picker"
|
|
20
|
-
| "open-review-mode"
|
|
20
|
+
| "open-review-mode"
|
|
21
|
+
| "select-prev-group"
|
|
22
|
+
| "select-next-group";
|
|
21
23
|
|
|
22
24
|
interface ResolveDashboardCloseActionInput {
|
|
23
25
|
showStopPicker: boolean;
|
|
@@ -63,6 +65,8 @@ interface ResolveDashboardKeyActionInput extends ResolveDashboardCloseActionInpu
|
|
|
63
65
|
hasCurrentSession: boolean;
|
|
64
66
|
canFixPendingSession: boolean;
|
|
65
67
|
isRunSpawning: boolean;
|
|
68
|
+
sidebarFocused?: boolean;
|
|
69
|
+
sessionGroupCount?: number;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
export function resolveDashboardKeyAction({
|
|
@@ -76,6 +80,8 @@ export function resolveDashboardKeyAction({
|
|
|
76
80
|
hasCurrentSession,
|
|
77
81
|
canFixPendingSession,
|
|
78
82
|
isRunSpawning,
|
|
83
|
+
sidebarFocused = false,
|
|
84
|
+
sessionGroupCount = 0,
|
|
79
85
|
}: ResolveDashboardKeyActionInput): DashboardKeyAction {
|
|
80
86
|
if (keyName === "q" || keyName === "escape") {
|
|
81
87
|
return resolveDashboardCloseAction({
|
|
@@ -91,6 +97,16 @@ export function resolveDashboardKeyAction({
|
|
|
91
97
|
return "none";
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
if (sidebarFocused && sessionGroupCount >= 2) {
|
|
101
|
+
if (keyName === "up" || keyName === "k") {
|
|
102
|
+
return "select-prev-group";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (keyName === "down" || keyName === "j") {
|
|
106
|
+
return "select-next-group";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
if (keyName === "tab" || keyName === "right") {
|
|
95
111
|
return "cycle-focus";
|
|
96
112
|
}
|
|
@@ -15,10 +15,18 @@ export interface DashboardRunControl {
|
|
|
15
15
|
isStartupSpawning: () => boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
interface DashboardRunControlDeps {
|
|
19
|
+
spawn?: typeof Bun.spawn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useDashboardRunControl(
|
|
23
|
+
projectPath: string,
|
|
24
|
+
deps?: DashboardRunControlDeps
|
|
25
|
+
): DashboardRunControl {
|
|
19
26
|
const [runError, setRunError] = useState<string | null>(null);
|
|
20
27
|
const [startupMode, setStartupMode] = useState<DashboardStartupMode>(null);
|
|
21
28
|
const isStartupSpawningRef = useRef(false);
|
|
29
|
+
const spawn = deps?.spawn ?? Bun.spawn;
|
|
22
30
|
|
|
23
31
|
const clearRunError = useCallback(() => {
|
|
24
32
|
setRunError(null);
|
|
@@ -40,7 +48,7 @@ export function useDashboardRunControl(projectPath: string): DashboardRunControl
|
|
|
40
48
|
setStartupMode(nextStartupMode);
|
|
41
49
|
|
|
42
50
|
try {
|
|
43
|
-
const subprocess =
|
|
51
|
+
const subprocess = spawn([process.execPath, CLI_PATH, command, ...argv], {
|
|
44
52
|
cwd: projectPath,
|
|
45
53
|
stdin: "ignore",
|
|
46
54
|
stdout: "pipe",
|
|
@@ -73,7 +81,7 @@ export function useDashboardRunControl(projectPath: string): DashboardRunControl
|
|
|
73
81
|
setRunError(getErrorMessage(error));
|
|
74
82
|
}
|
|
75
83
|
},
|
|
76
|
-
[projectPath]
|
|
84
|
+
[projectPath, spawn]
|
|
77
85
|
);
|
|
78
86
|
|
|
79
87
|
const spawnRunProcess = useCallback(
|
|
@@ -22,13 +22,17 @@ export interface DashboardStopControl {
|
|
|
22
22
|
stopSelectedSession: (session: ActiveSession) => Promise<void>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
interface DashboardStopControlDeps {
|
|
26
|
+
stopActiveSession?: typeof stopActiveSession;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useDashboardStopControl(
|
|
30
|
+
options: DashboardStopControlOptions,
|
|
31
|
+
deps?: DashboardStopControlDeps
|
|
32
|
+
): DashboardStopControl {
|
|
30
33
|
const [stoppingSession, setStoppingSession] = useState<StoppingSessionState | null>(null);
|
|
31
34
|
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
35
|
+
const stopSession = deps?.stopActiveSession ?? stopActiveSession;
|
|
32
36
|
|
|
33
37
|
const clearSettleTimer = useMemo(
|
|
34
38
|
() => () => {
|
|
@@ -42,7 +46,10 @@ export function useDashboardStopControl({
|
|
|
42
46
|
|
|
43
47
|
if (
|
|
44
48
|
stoppingSession &&
|
|
45
|
-
shouldClearStoppingSessionState({
|
|
49
|
+
shouldClearStoppingSessionState({
|
|
50
|
+
marker: stoppingSession,
|
|
51
|
+
currentSession: options.currentSession,
|
|
52
|
+
})
|
|
46
53
|
) {
|
|
47
54
|
clearSettleTimer();
|
|
48
55
|
setStoppingSession(null);
|
|
@@ -57,8 +64,8 @@ export function useDashboardStopControl({
|
|
|
57
64
|
|
|
58
65
|
try {
|
|
59
66
|
await stopSelectedDashboardSession(session, {
|
|
60
|
-
setShowStopPicker,
|
|
61
|
-
stopActiveSession,
|
|
67
|
+
setShowStopPicker: options.setShowStopPicker,
|
|
68
|
+
stopActiveSession: stopSession,
|
|
62
69
|
});
|
|
63
70
|
|
|
64
71
|
const settled = settleStoppingSessionState(createStoppingSessionState(session));
|
|
@@ -78,10 +85,10 @@ export function useDashboardStopControl({
|
|
|
78
85
|
} catch (error) {
|
|
79
86
|
clearSettleTimer();
|
|
80
87
|
setStoppingSession(null);
|
|
81
|
-
onError(getErrorMessage(error));
|
|
88
|
+
options.onError(getErrorMessage(error));
|
|
82
89
|
}
|
|
83
90
|
},
|
|
84
|
-
[clearSettleTimer, onError, setShowStopPicker]
|
|
91
|
+
[clearSettleTimer, options.onError, options.setShowStopPicker, stopSession]
|
|
85
92
|
);
|
|
86
93
|
|
|
87
94
|
return {
|
|
@@ -4,16 +4,21 @@ import { SessionItem } from "./SessionItem";
|
|
|
4
4
|
|
|
5
5
|
interface SessionGroupProps {
|
|
6
6
|
group: SessionGroupData;
|
|
7
|
-
|
|
7
|
+
isSelected: boolean;
|
|
8
|
+
sidebarFocused?: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function SessionGroup({ group,
|
|
11
|
+
export function SessionGroup({ group, isSelected, sidebarFocused = false }: SessionGroupProps) {
|
|
11
12
|
const icon = group.isCurrentProject ? "◆" : "○";
|
|
12
|
-
const
|
|
13
|
+
const baseNameColor = group.isCurrentProject ? TUI_COLORS.text.primary : TUI_COLORS.text.muted;
|
|
14
|
+
const nameColor = isSelected ? TUI_COLORS.text.primary : baseNameColor;
|
|
15
|
+
const headerBg = isSelected ? (sidebarFocused ? "#1e293b" : "#111827") : undefined;
|
|
16
|
+
const caret = isSelected ? "›" : " ";
|
|
13
17
|
|
|
14
18
|
return (
|
|
15
19
|
<box flexDirection="column">
|
|
16
|
-
<box flexDirection="row" gap={1} paddingLeft={1}>
|
|
20
|
+
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1} backgroundColor={headerBg}>
|
|
21
|
+
<text fg={isSelected ? TUI_COLORS.accent.key : TUI_COLORS.text.dim}>{caret}</text>
|
|
17
22
|
<text fg={nameColor}>{icon}</text>
|
|
18
23
|
<text fg={nameColor} wrapMode="none">
|
|
19
24
|
<strong>{group.projectName}</strong>
|
|
@@ -23,12 +28,7 @@ export function SessionGroup({ group, selectedSessionId }: SessionGroupProps) {
|
|
|
23
28
|
)}
|
|
24
29
|
</box>
|
|
25
30
|
{group.sessions.map((session) => (
|
|
26
|
-
<SessionItem
|
|
27
|
-
key={session.sessionId}
|
|
28
|
-
session={session}
|
|
29
|
-
isSelected={session.sessionId === selectedSessionId}
|
|
30
|
-
projectName={group.projectName}
|
|
31
|
-
/>
|
|
31
|
+
<SessionItem key={session.sessionId} session={session} projectName={group.projectName} />
|
|
32
32
|
))}
|
|
33
33
|
{group.sessions.length === 0 && (
|
|
34
34
|
<text fg={TUI_COLORS.text.dim} paddingLeft={4}>
|
|
@@ -3,7 +3,6 @@ import { TUI_COLORS } from "@/lib/tui/shared/colors";
|
|
|
3
3
|
|
|
4
4
|
interface SessionItemProps {
|
|
5
5
|
session: ActiveSession;
|
|
6
|
-
isSelected: boolean;
|
|
7
6
|
projectName?: string;
|
|
8
7
|
}
|
|
9
8
|
|
|
@@ -32,16 +31,14 @@ function getStatusIcon(state: string): { icon: string; color: string } {
|
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
export function SessionItem({ session,
|
|
34
|
+
export function SessionItem({ session, projectName }: SessionItemProps) {
|
|
36
35
|
const { icon, color } = getStatusIcon(session.state);
|
|
37
|
-
const bgColor = isSelected ? "#1e293b" : undefined;
|
|
38
|
-
const textColor = isSelected ? TUI_COLORS.text.primary : TUI_COLORS.text.muted;
|
|
39
36
|
const label = shortSessionLabel(session.sessionName, projectName);
|
|
40
37
|
|
|
41
38
|
return (
|
|
42
|
-
<box flexDirection="row" gap={1} paddingLeft={2} paddingRight={1}
|
|
39
|
+
<box flexDirection="row" gap={1} paddingLeft={2} paddingRight={1}>
|
|
43
40
|
<text fg={color}>{icon}</text>
|
|
44
|
-
<text fg={
|
|
41
|
+
<text fg={TUI_COLORS.text.muted} wrapMode="none" flexShrink={1}>
|
|
45
42
|
{label}
|
|
46
43
|
</text>
|
|
47
44
|
</box>
|
|
@@ -4,13 +4,13 @@ import { SessionGroup } from "./SessionGroup";
|
|
|
4
4
|
|
|
5
5
|
interface SessionSidebarProps {
|
|
6
6
|
groups: SessionGroupData[];
|
|
7
|
-
|
|
7
|
+
selectedGroupPath: string | null;
|
|
8
8
|
focused?: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function SessionSidebar({
|
|
12
12
|
groups,
|
|
13
|
-
|
|
13
|
+
selectedGroupPath,
|
|
14
14
|
focused = false,
|
|
15
15
|
}: SessionSidebarProps) {
|
|
16
16
|
const borderColor = focused ? TUI_COLORS.ui.borderFocused : TUI_COLORS.ui.border;
|
|
@@ -38,7 +38,8 @@ export function SessionSidebar({
|
|
|
38
38
|
<SessionGroup
|
|
39
39
|
key={group.projectPath}
|
|
40
40
|
group={group}
|
|
41
|
-
|
|
41
|
+
isSelected={group.projectPath === selectedGroupPath}
|
|
42
|
+
sidebarFocused={focused}
|
|
42
43
|
/>
|
|
43
44
|
))
|
|
44
45
|
)}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import type { stopActiveSession } from "@/lib/stop-session";
|
|
2
|
+
import type { DashboardOverlays } from "@/lib/tui/dashboard/DashboardOverlays";
|
|
3
|
+
import type { useWorkspaceState } from "@/lib/tui/workspace/use-workspace-state";
|
|
4
|
+
|
|
1
5
|
export interface DashboardProps {
|
|
2
6
|
projectPath: string;
|
|
3
7
|
branch?: string;
|
|
4
8
|
refreshInterval?: number;
|
|
9
|
+
deps?: {
|
|
10
|
+
useWorkspaceState?: typeof useWorkspaceState;
|
|
11
|
+
DashboardOverlays?: typeof DashboardOverlays;
|
|
12
|
+
spawn?: typeof Bun.spawn;
|
|
13
|
+
stopActiveSession?: typeof stopActiveSession;
|
|
14
|
+
};
|
|
5
15
|
}
|
|
@@ -7,7 +7,7 @@ import type { FocusedPane, SessionGroupData } from "./workspace-types";
|
|
|
7
7
|
|
|
8
8
|
interface WorkspaceProps extends DetailPaneProps {
|
|
9
9
|
sessionGroups: SessionGroupData[];
|
|
10
|
-
|
|
10
|
+
selectedGroupPath: string | null;
|
|
11
11
|
outputVisible: boolean;
|
|
12
12
|
focusedPane: FocusedPane;
|
|
13
13
|
overlayBlocked?: boolean;
|
|
@@ -15,7 +15,7 @@ interface WorkspaceProps extends DetailPaneProps {
|
|
|
15
15
|
|
|
16
16
|
export function Workspace({
|
|
17
17
|
sessionGroups,
|
|
18
|
-
|
|
18
|
+
selectedGroupPath,
|
|
19
19
|
outputVisible,
|
|
20
20
|
focusedPane,
|
|
21
21
|
overlayBlocked = false,
|
|
@@ -31,7 +31,7 @@ export function Workspace({
|
|
|
31
31
|
<box flexDirection="row" flexGrow={1} minHeight={0} gap={1} paddingLeft={1} paddingRight={1}>
|
|
32
32
|
<SessionSidebar
|
|
33
33
|
groups={sessionGroups}
|
|
34
|
-
|
|
34
|
+
selectedGroupPath={selectedGroupPath}
|
|
35
35
|
focused={sidebarFocused}
|
|
36
36
|
/>
|
|
37
37
|
<DetailPane {...detailPaneProps} focused={detailFocused} />
|
|
@@ -85,7 +85,7 @@ export function createInitialWorkspaceState(
|
|
|
85
85
|
sessionGroups: [],
|
|
86
86
|
allSessions: [],
|
|
87
87
|
projectSessions: [],
|
|
88
|
-
|
|
88
|
+
selectedGroupPath: null,
|
|
89
89
|
currentSession: null,
|
|
90
90
|
logEntries: [],
|
|
91
91
|
fixes: [],
|
|
@@ -121,6 +121,36 @@ export function createInitialWorkspaceState(
|
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function buildDetailResetState(selectedGroupPath: string): Partial<WorkspaceState> {
|
|
125
|
+
const initial = createInitialWorkspaceState();
|
|
126
|
+
return {
|
|
127
|
+
currentSession: initial.currentSession,
|
|
128
|
+
logEntries: initial.logEntries,
|
|
129
|
+
fixes: initial.fixes,
|
|
130
|
+
skipped: initial.skipped,
|
|
131
|
+
findings: initial.findings,
|
|
132
|
+
storedFindings: initial.storedFindings,
|
|
133
|
+
selectedFindingIds: initial.selectedFindingIds,
|
|
134
|
+
selectedFindings: initial.selectedFindings,
|
|
135
|
+
unselectedFindings: initial.unselectedFindings,
|
|
136
|
+
fixResults: initial.fixResults,
|
|
137
|
+
unresolvedSelectedFindings: initial.unresolvedSelectedFindings,
|
|
138
|
+
auditRegressionFindings: initial.auditRegressionFindings,
|
|
139
|
+
iterationFixes: initial.iterationFixes,
|
|
140
|
+
iterationSkipped: initial.iterationSkipped,
|
|
141
|
+
iterationFindings: initial.iterationFindings,
|
|
142
|
+
latestReviewIteration: initial.latestReviewIteration,
|
|
143
|
+
codexReviewText: initial.codexReviewText,
|
|
144
|
+
tmuxOutput: initial.tmuxOutput,
|
|
145
|
+
elapsed: initial.elapsed,
|
|
146
|
+
lastSessionStats: initial.lastSessionStats,
|
|
147
|
+
projectStats: initial.projectStats,
|
|
148
|
+
currentAgent: initial.currentAgent,
|
|
149
|
+
reviewOptions: initial.reviewOptions,
|
|
150
|
+
selectedGroupPath,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
function buildSessionGroups(
|
|
125
155
|
allSessions: ActiveSession[],
|
|
126
156
|
currentProjectPath: string
|
|
@@ -163,8 +193,10 @@ export function useWorkspaceState(
|
|
|
163
193
|
projectPath: string,
|
|
164
194
|
_branch?: string,
|
|
165
195
|
refreshInterval: number = DEFAULT_REFRESH_INTERVAL,
|
|
166
|
-
deps: WorkspaceStateDeps = defaultWorkspaceStateDeps
|
|
196
|
+
deps: WorkspaceStateDeps = defaultWorkspaceStateDeps,
|
|
197
|
+
selectedGroupPath?: string
|
|
167
198
|
): WorkspaceState {
|
|
199
|
+
const detailPath = selectedGroupPath ?? projectPath;
|
|
168
200
|
const [state, setState] = useState<WorkspaceState>(() => createInitialWorkspaceState());
|
|
169
201
|
|
|
170
202
|
const stateRef = useRef(state);
|
|
@@ -179,6 +211,23 @@ export function useWorkspaceState(
|
|
|
179
211
|
const lastLiveMetaRef = useRef<LiveRefreshMeta | null>(null);
|
|
180
212
|
const logIncrementalStateRef = useRef<LogIncrementalState | undefined>(undefined);
|
|
181
213
|
const lastLogSessionPathRef = useRef<string | null>(null);
|
|
214
|
+
const lastDetailPathRef = useRef<string>(detailPath);
|
|
215
|
+
|
|
216
|
+
const detailPathChanged = lastDetailPathRef.current !== detailPath;
|
|
217
|
+
if (detailPathChanged) {
|
|
218
|
+
lastDetailPathRef.current = detailPath;
|
|
219
|
+
logIncrementalStateRef.current = undefined;
|
|
220
|
+
lastLogSessionPathRef.current = null;
|
|
221
|
+
lastTmuxOutputRef.current = "";
|
|
222
|
+
lastTmuxSessionRef.current = null;
|
|
223
|
+
lastTmuxCaptureRef.current = 0;
|
|
224
|
+
tmuxCaptureIntervalRef.current = deps.tmuxCaptureMinIntervalMs;
|
|
225
|
+
lastLiveMetaRef.current = null;
|
|
226
|
+
setState((prev) => ({
|
|
227
|
+
...prev,
|
|
228
|
+
...buildDetailResetState(detailPath),
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
182
231
|
|
|
183
232
|
const refreshHeavy = useCallback(async () => {
|
|
184
233
|
if (isHeavyRefreshingRef.current) return;
|
|
@@ -190,19 +239,17 @@ export function useWorkspaceState(
|
|
|
190
239
|
deps.ensureGitRepositoryAsync(projectPath),
|
|
191
240
|
deps.listAllActiveSessions(),
|
|
192
241
|
deps.listProjectActiveSessions(undefined, projectPath),
|
|
193
|
-
deps.getLatestProjectActiveSession(undefined,
|
|
194
|
-
deps.getLatestProjectLogSession(undefined,
|
|
242
|
+
deps.getLatestProjectActiveSession(undefined, detailPath),
|
|
243
|
+
deps.getLatestProjectLogSession(undefined, detailPath),
|
|
195
244
|
loadWorkspaceConfigSafe(projectPath, deps.loadEffectiveConfig),
|
|
196
245
|
]);
|
|
197
246
|
|
|
198
247
|
const sessionGroups = buildSessionGroups(allSessions, projectPath);
|
|
199
248
|
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
? prevSelectedId
|
|
205
|
-
: (currentSession?.sessionId ?? allSessions[0]?.sessionId ?? null);
|
|
249
|
+
const selectedGroupExists = sessionGroups.some((group) => group.projectPath === detailPath);
|
|
250
|
+
const resolvedSelectedGroupPath = selectedGroupExists
|
|
251
|
+
? detailPath
|
|
252
|
+
: (sessionGroups[0]?.projectPath ?? projectPath);
|
|
206
253
|
|
|
207
254
|
let logEntries = stateRef.current.logEntries;
|
|
208
255
|
let nextLogIncrementalState = logIncrementalStateRef.current;
|
|
@@ -251,12 +298,12 @@ export function useWorkspaceState(
|
|
|
251
298
|
let projectStats: ProjectStats | null = null;
|
|
252
299
|
|
|
253
300
|
if (!currentSession) {
|
|
254
|
-
const projectLogSessions = await deps.listProjectLogSessions(undefined,
|
|
301
|
+
const projectLogSessions = await deps.listProjectLogSessions(undefined, detailPath);
|
|
255
302
|
const latestSession = projectLogSessions[0];
|
|
256
303
|
if (latestSession) {
|
|
257
304
|
lastSessionStats = await deps.computeSessionStats(latestSession);
|
|
258
305
|
projectStats = await deps.computeProjectStats(
|
|
259
|
-
deps.getProjectName(
|
|
306
|
+
deps.getProjectName(detailPath),
|
|
260
307
|
projectLogSessions
|
|
261
308
|
);
|
|
262
309
|
}
|
|
@@ -270,7 +317,7 @@ export function useWorkspaceState(
|
|
|
270
317
|
sessionGroups,
|
|
271
318
|
allSessions,
|
|
272
319
|
projectSessions,
|
|
273
|
-
|
|
320
|
+
selectedGroupPath: resolvedSelectedGroupPath,
|
|
274
321
|
currentSession,
|
|
275
322
|
logEntries,
|
|
276
323
|
fixes,
|
|
@@ -306,14 +353,14 @@ export function useWorkspaceState(
|
|
|
306
353
|
} finally {
|
|
307
354
|
isHeavyRefreshingRef.current = false;
|
|
308
355
|
}
|
|
309
|
-
}, [deps, projectPath]);
|
|
356
|
+
}, [deps, projectPath, detailPath]);
|
|
310
357
|
|
|
311
358
|
const refreshLive = useCallback(async () => {
|
|
312
359
|
if (isLiveRefreshingRef.current) return;
|
|
313
360
|
isLiveRefreshingRef.current = true;
|
|
314
361
|
|
|
315
362
|
try {
|
|
316
|
-
const currentSession = await deps.getLatestProjectActiveSession(undefined,
|
|
363
|
+
const currentSession = await deps.getLatestProjectActiveSession(undefined, detailPath);
|
|
317
364
|
|
|
318
365
|
let tmuxOutput = lastTmuxOutputRef.current;
|
|
319
366
|
const liveMeta = getLiveRefreshMeta(currentSession);
|
|
@@ -374,7 +421,7 @@ export function useWorkspaceState(
|
|
|
374
421
|
} finally {
|
|
375
422
|
isLiveRefreshingRef.current = false;
|
|
376
423
|
}
|
|
377
|
-
}, [deps,
|
|
424
|
+
}, [deps, detailPath]);
|
|
378
425
|
|
|
379
426
|
useEffect(() => {
|
|
380
427
|
void refreshHeavy();
|
|
@@ -30,7 +30,7 @@ export interface WorkspaceState {
|
|
|
30
30
|
sessionGroups: SessionGroupData[];
|
|
31
31
|
allSessions: ActiveSession[];
|
|
32
32
|
projectSessions: ActiveSession[];
|
|
33
|
-
|
|
33
|
+
selectedGroupPath: string | null;
|
|
34
34
|
currentSession: SessionState | null;
|
|
35
35
|
logEntries: LogEntry[];
|
|
36
36
|
fixes: FixEntry[];
|