fix: preview panes in split groups + restart all sessions - #43
Conversation
The sidebar's two-pass rendering was skipping group lookup for preview panes, so they always rendered as standalone items even when part of a split-pane group. Now both sessions and previews participate in group membership checks, and GroupContainer renders mixed member types with appropriate icons and close buttons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| // Respawn with same session ID and working directory | ||
| const args: string[] = ["--resume", session.claudeSessionId]; | ||
| const pty = spawn(binary, args, { | ||
| name: "xterm-256color", |
There was a problem hiding this comment.
🔴 Critical
Problem: shuttingDown is reset to false synchronously immediately after managed.pty.kill(), but the old PTY's onExit callback fires asynchronously — on the next event loop tick, after this synchronous loop iteration has already cleared the flag. That means every old onExit handler fires with shuttingDown === false, triggering both sessions.delete(id) and removePersistedSession(session.claudeSessionId) — which permanently removes each restarted session from the map and deletes the Claude session file the new PTY needs for --resume.
Why it matters: restartAllSessions() will silently destroy every session instead of restarting them. The dashboard will show all sessions as gone after the restart completes.
Suggested fix: Track restarting IDs separately so the old onExit can skip cleanup:
// module-level
const restartingIds = new Set<string>();
// In restartAllSessions, replace the shuttingDown toggle:
restartingIds.add(id);
try {
managed.pty.kill();
} catch {
// best-effort
}
// (remove the shuttingDown = false line entirely)
// In the original spawn's onExit (and the new one below):
pty.onExit(() => {
session.status = "stopped";
session.updatedAt = Date.now();
if (!shuttingDown && !restartingIds.has(id)) {
if (session.claudeSessionId) removePersistedSession(session.claudeSessionId);
sessions.delete(id);
}
restartingIds.delete(id); // always clean up
});This requires the same guard in the original createSession spawn path too — or alternatively, add a restarting?: boolean field to ManagedSession and check that instead.
There was a problem hiding this comment.
Already addressed in the latest push — rewrote to snapshot all session info first, then kill everything under shuttingDown=true, clear the map, set shuttingDown=false, then respawn via createSession. The old per-session toggle approach is gone.
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||
| const data = await res.json(); | ||
| setState("done"); | ||
| setTimeout(() => setState("idle"), 2000); |
There was a problem hiding this comment.
🟡 Warning
Problem: The catch block in handleRestart silently swallows errors — both network failures and non-2xx responses — and just resets state to "idle" with no user feedback.
Why it matters: If the server returns a 500 or the fetch fails entirely, the user sees the button return to normal and has no idea whether the restart succeeded, partially ran, or never executed. With destructive-adjacent actions (killing all live PTYs), silent failure is especially bad.
Suggested fix:
const [error, setError] = useState<string | null>(null);
// In handleRestart catch:
} catch (err) {
setError(err instanceof Error ? err.message : "Restart failed");
setState("idle");
}
// In render, show the error:
{error && (
<div className="text-xs mt-1" style={{ color: "#ea6c73" }}>
{error}
</div>
)}Also: the data variable from await res.json() on line 97 is assigned but never used — can safely remove it.
There was a problem hiding this comment.
Fixed — added error state to RestartAllButton. Now shows error message on failure instead of silently resetting. Also removed unused data variable.
nox-0x
left a comment
There was a problem hiding this comment.
Preview-panes-in-groups logic looks solid — the GroupMember union type and two-pass rendering refactor are clean. One critical bug in restartAllSessions: shuttingDown is reset to false synchronously before the old PTYs' async onExit handlers fire, so those handlers see shuttingDown === false and call sessions.delete(id) + removePersistedSession() on the just-restarted sessions — effectively destroying them all. Also flagged silent failure in the restart button's catch block.
de9b304 to
8e0e42d
Compare
nox-0x
left a comment
There was a problem hiding this comment.
Reviewed the new commit (de9b304) — the remapSessionIds store logic and the Sidebar GroupMember refactor look correct. However, both issues flagged in the previous review remain unresolved in the newly added code:
-
🔴
shuttingDownasync race (sessions.ts):shuttingDownis still reset tofalsesynchronously before the old PTYs'onExithandlers fire. Those handlers see!shuttingDown === trueand callremovePersistedSession(session.claudeSessionId)— deleting the Claude session files the newly-spawned PTYs need for--resume. See the inline comment for the suggested fix using arestartingIds: Set<string>guard. -
🟡 Silent failure in
RestartAllButton(SettingsStatusBarItem.tsx): Thecatchblock still resets to"idle"with no user feedback. On a destructive operation like killing all PTYs, silent failure is especially bad.
Neither is in new code paths added by this commit — they were both present and called out before. Please address before merging.
nox-0x
left a comment
There was a problem hiding this comment.
New commit (8e0e42d) correctly adds remapSessionIds to the store and threads closePreview through GroupContainer — both look good. However the two issues flagged in prior reviews are still unresolved in this commit:
🔴 Critical — shuttingDown async race (sessions.ts): shuttingDown is reset to false synchronously before the old PTYs' onExit callbacks fire. Those handlers see !shuttingDown === true and call removePersistedSession(session.claudeSessionId), deleting the Claude session files the newly-spawned PTYs need for --resume. The practical result is every session is permanently destroyed instead of restarted. Fix: use a restartingIds: Set<string> guard as described in the inline comment.
🟡 Warning — silent failure in RestartAllButton (SettingsStatusBarItem.tsx): catch block still resets to "idle" with no user-visible error. For an operation that kills all live PTYs, silent failure is especially bad.
Please address the critical race before merging — it will cause real data loss in production.
8e0e42d to
f512ed9
Compare
nox-0x
left a comment
There was a problem hiding this comment.
New commit (f512ed9) adds remapSessionIds and closePreview correctly — both look good. However the two issues flagged across all prior review rounds remain unresolved:
🔴 Critical — shuttingDown async race (sessions.ts): shuttingDown is reset to false synchronously before the old PTYs' onExit callbacks fire. Those handlers see !shuttingDown === true and call removePersistedSession(session.claudeSessionId), deleting the Claude session files the newly-spawned PTYs need for --resume. Every restarted session will be permanently destroyed instead of resumed. Fix: use a restartingIds: Set<string> guard as described in the inline comment on sessions.ts.
🟡 Warning — silent failure in RestartAllButton (SettingsStatusBarItem.tsx): The catch block resets to "idle" with no user-visible error. For an operation that kills all live PTYs, silent failure is especially dangerous — user has no idea if the restart succeeded or not.
The sidebar GroupMember refactor and the store remap logic are solid. Ready to approve once the critical race is addressed.
f512ed9 to
78442eb
Compare
- Fix restart killing sessions permanently: old approach toggled shuttingDown per-session, but onExit fired async after the flag was reset, deleting sessions. New approach: snapshot all session info, kill everything under shuttingDown=true, clear map, then respawn via createSession. - Return old→new session ID mapping from restart endpoint so the dashboard can remap layout tree, groups, paneOrder, and activePane. - Add remapSessionIds store action that walks the layout tree and updates all pane references to new IDs. - RestartAllButton calls remapSessionIds after successful restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
78442eb to
c253ddf
Compare
nox-0x
left a comment
There was a problem hiding this comment.
🟡 Silent failure in RestartAllButton — FIXED ✅ Error state is now rendered.
🔴 Critical — shuttingDown async race in sessions.ts — Still present in this commit.
The sequence is: shuttingDown = true → kill PTYs → sessions.clear() → shuttingDown = false → respawn. The old PTYs onExit callbacks fire after this synchronous block, when shuttingDown is already false. So !shuttingDown is true in every handler, and removePersistedSession(session.claudeSessionId) runs — which removes the newly-created session from the persistence store (the new session was just persisted under the same claudeSessionId). Sessions work fine in-memory for the current run but will not be restored on server restart.
Fix: use a restartingIds: Set<string> guard as documented in the inline comment. Guard removePersistedSession with !restartingIds.has(id) in the onExit handler, and delete from the set inside the callback.
Sidebar GroupMember refactor, store remapSessionIds, and error handling all look good. One issue blocking merge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| onClick={() => { | ||
| setEditing(true); | ||
| setDraft(""); | ||
| onChange(""); |
There was a problem hiding this comment.
🟡 Warning
Problem: onChange("") is called immediately when the user clicks the edit (pencil) button — before they have typed or confirmed anything.
Why it matters: onChange presumably triggers a debounced save to the server. Clicking "edit" on a field that contains a valid API key instantly overwrites it with an empty string on the server, even if the user changes their mind and navigates away without submitting. The current value is silently destroyed on the first click.
Suggested fix: Remove the eager onChange("") call from the edit-button handler. Only call onChange when the user explicitly saves (e.g., on Enter / blur with a confirmed value):
onClick={() => {
setEditing(true);
setDraft(""); // pre-fill draft as empty so user types fresh
// do NOT call onChange here — only call it on confirmed save
}}If clearing immediately is intentional UX (show-as-blank-while-editing), add a local displayValue derived from editing ? draft : savedValue so the visual state and the persisted state stay decoupled.
nox-0x
left a comment
There was a problem hiding this comment.
Error handling in RestartAllButton is fixed ✅ — setError is wired up and the error div renders correctly. Sidebar GroupMember refactor and remapSessionIds store logic look solid.
Two issues still blocking:
🔴 Critical — shuttingDown async race (sessions.ts): The synchronous shuttingDown = false reset happens before old PTY onExit callbacks fire. Those handlers see !shuttingDown === true and call removePersistedSession(session.claudeSessionId), deleting the Claude session files the newly-spawned PTYs need for --resume. Sessions work in-memory for the current run but will not restore on server restart. Fix documented in the existing inline comment: use a restartingIds: Set<string> guard.
🟡 Warning — premature onChange("") in SettingRow: Clicking the edit pencil now calls onChange("") before the user types anything, silently overwriting a valid saved value (e.g., an API key) with an empty string on the server. New inline comment with fix on line 46.
Summary
Test plan
🤖 Generated with Claude Code