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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-review",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Orchestrating coding agents for code review, verification and fixing via the ralph loop.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
 
@@ -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 state = useWorkspaceState(projectPath, branch, refreshInterval);
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
- currentSession: state.currentSession,
42
- setShowStopPicker,
43
- onError: setRunError,
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
- selectedSessionId={state.selectedSessionId}
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
- <DashboardOverlays
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
- export function useDashboardRunControl(projectPath: string): DashboardRunControl {
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 = Bun.spawn([process.execPath, CLI_PATH, command, ...argv], {
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
- export function useDashboardStopControl({
26
- currentSession,
27
- setShowStopPicker,
28
- onError,
29
- }: DashboardStopControlOptions): DashboardStopControl {
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({ marker: stoppingSession, currentSession })
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
- selectedSessionId: string | null;
7
+ isSelected: boolean;
8
+ sidebarFocused?: boolean;
8
9
  }
9
10
 
10
- export function SessionGroup({ group, selectedSessionId }: SessionGroupProps) {
11
+ export function SessionGroup({ group, isSelected, sidebarFocused = false }: SessionGroupProps) {
11
12
  const icon = group.isCurrentProject ? "◆" : "○";
12
- const nameColor = group.isCurrentProject ? TUI_COLORS.text.primary : TUI_COLORS.text.muted;
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, isSelected, projectName }: SessionItemProps) {
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} backgroundColor={bgColor}>
39
+ <box flexDirection="row" gap={1} paddingLeft={2} paddingRight={1}>
43
40
  <text fg={color}>{icon}</text>
44
- <text fg={textColor} wrapMode="none" flexShrink={1}>
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
- selectedSessionId: string | null;
7
+ selectedGroupPath: string | null;
8
8
  focused?: boolean;
9
9
  }
10
10
 
11
11
  export function SessionSidebar({
12
12
  groups,
13
- selectedSessionId,
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
- selectedSessionId={selectedSessionId}
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
- selectedSessionId: string | null;
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
- selectedSessionId,
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
- selectedSessionId={selectedSessionId}
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
- selectedSessionId: null,
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, projectPath),
194
- deps.getLatestProjectLogSession(undefined, projectPath),
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
- // Auto-select: prefer current selection if still alive, then latest project session
201
- const prevSelectedId = stateRef.current.selectedSessionId;
202
- const stillAlive = allSessions.find((s) => s.sessionId === prevSelectedId);
203
- const selectedSessionId = stillAlive
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, projectPath);
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(projectPath),
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
- selectedSessionId,
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, projectPath);
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, projectPath]);
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
- selectedSessionId: string | null;
33
+ selectedGroupPath: string | null;
34
34
  currentSession: SessionState | null;
35
35
  logEntries: LogEntry[];
36
36
  fixes: FixEntry[];