ralph-review 0.2.5 → 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.5",
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",
@@ -11,6 +11,7 @@ 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";
@@ -21,7 +22,14 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
21
22
  const renderer = useRenderer();
22
23
  const resolvedUseWorkspaceState = deps?.useWorkspaceState ?? useWorkspaceState;
23
24
  const ResolvedDashboardOverlays = deps?.DashboardOverlays ?? DashboardOverlays;
24
- const state = resolvedUseWorkspaceState(projectPath, branch, refreshInterval);
25
+ const [selectedGroupPath, setSelectedGroupPath] = useState<string>(projectPath);
26
+ const state = resolvedUseWorkspaceState(
27
+ projectPath,
28
+ branch,
29
+ refreshInterval,
30
+ undefined,
31
+ selectedGroupPath
32
+ );
25
33
  const {
26
34
  runError,
27
35
  startupMode,
@@ -85,6 +93,15 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
85
93
  setShowFixFindings(false);
86
94
  }
87
95
 
96
+ const resolvedSelectedGroupPath = resolveSelectedGroupPath(
97
+ state.sessionGroups,
98
+ selectedGroupPath,
99
+ projectPath
100
+ );
101
+ if (resolvedSelectedGroupPath !== selectedGroupPath) {
102
+ setSelectedGroupPath(resolvedSelectedGroupPath);
103
+ }
104
+
88
105
  const shutdown = useCallback(
89
106
  async (after?: () => Promise<void>, exitCode: number = 0) => {
90
107
  if (isExitingRef.current) {
@@ -124,6 +141,18 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
124
141
  setFocusedPane((current) => cycleDashboardFocusReverse(current, outputVisible));
125
142
  }, [outputVisible]);
126
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
+
127
156
  const handleKeyboard = useCallback(
128
157
  (key: { name: string }) => {
129
158
  const action = resolveDashboardKeyAction({
@@ -137,6 +166,8 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
137
166
  hasCurrentSession: Boolean(state.currentSession),
138
167
  canFixPendingSession,
139
168
  isRunSpawning: isStartupSpawning(),
169
+ sidebarFocused: focusedPane === "sidebar",
170
+ sessionGroupCount: state.sessionGroups.length,
140
171
  });
141
172
 
142
173
  switch (action) {
@@ -187,6 +218,12 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
187
218
  clearRunError();
188
219
  setShowReviewModeOverlay(true);
189
220
  return;
221
+ case "select-prev-group":
222
+ selectPrevGroup();
223
+ return;
224
+ case "select-next-group":
225
+ selectNextGroup();
226
+ return;
190
227
  }
191
228
  },
192
229
  [
@@ -194,7 +231,10 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
194
231
  clearRunError,
195
232
  cycleFocus,
196
233
  cycleFocusReverse,
234
+ focusedPane,
197
235
  isStartupSpawning,
236
+ selectNextGroup,
237
+ selectPrevGroup,
198
238
  showFixFindings,
199
239
  showHelp,
200
240
  showReviewModeOverlay,
@@ -203,6 +243,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
203
243
  shutdown,
204
244
  state.allSessions,
205
245
  state.currentSession,
246
+ state.sessionGroups.length,
206
247
  stopSelectedSession,
207
248
  ]
208
249
  );
@@ -238,7 +279,7 @@ export function Dashboard({ projectPath, branch, refreshInterval = 1000, deps }:
238
279
  ) : (
239
280
  <Workspace
240
281
  sessionGroups={state.sessionGroups}
241
- selectedSessionId={state.selectedSessionId}
282
+ selectedGroupPath={selectedGroupPath}
242
283
  session={state.currentSession}
243
284
  fixes={state.fixes}
244
285
  skipped={state.skipped}
@@ -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
  }
@@ -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
  )}
@@ -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[];