mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge master into the 1.41.0 release branch (faster session close, README GIFs)
This commit is contained in:
commit
209a539489
17 files changed
+492
-66
No files matched your search
+9
-3
@@ -106,6 +106,7 @@ import {
|
||||
getClaudeBinaryPath,
|
||||
spawnPtyWithHelperRepair,
|
||||
resolveLocalShell,
|
||||
waitForProcessesExit,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -179,7 +180,10 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
|
||||
|
||||
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
||||
|
||||
/** Graceful shutdown delay when stopping session (100ms) */
|
||||
/**
|
||||
* Longest the PTY process gets to exit on SIGTERM before SIGKILL when stopping a
|
||||
* session. A deadline, not a sleep: stop() moves on as soon as it has exited.
|
||||
*/
|
||||
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
||||
|
||||
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
|
||||
@@ -4801,8 +4805,10 @@ export class Session extends EventEmitter {
|
||||
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
||||
}
|
||||
|
||||
// Give it a moment to terminate gracefully
|
||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
||||
// Give it a moment to terminate gracefully. For a tmux-backed session this
|
||||
// is the attach client, gone within a few ms of SIGTERM, and this used to be
|
||||
// a fixed 100ms sleep on every close.
|
||||
if (pid) await waitForProcessesExit([pid], { timeoutMs: GRACEFUL_SHUTDOWN_DELAY_MS });
|
||||
|
||||
// Force kill with SIGKILL if still alive
|
||||
try {
|
||||
|
||||
+28
-6
@@ -761,13 +761,35 @@ export class SubagentWatcher extends EventEmitter {
|
||||
* by workingDir alone would kill subagents belonging to OTHER sessions.
|
||||
*/
|
||||
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
|
||||
const subagents = this.getSubagentsForSession(workingDir);
|
||||
for (const agent of subagents) {
|
||||
if (agent.status === 'active' || agent.status === 'idle') {
|
||||
// Only kill subagents belonging to this specific session
|
||||
if (sessionId && agent.sessionId !== sessionId) continue;
|
||||
await this.killSubagent(agent.agentId);
|
||||
const targets = this.getSubagentsForSession(workingDir).filter(
|
||||
// Only kill subagents belonging to this specific session
|
||||
(agent) => (agent.status === 'active' || agent.status === 'idle') && (!sessionId || agent.sessionId === sessionId)
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
// ONE process scan for the lot. This used to call killSubagent() per agent, and
|
||||
// each call ran its own `pgrep -f claude` plus a /proc read per match (~85ms on a
|
||||
// box with ~100 matching processes), so closing a session right after a workflow
|
||||
// paid that once per recently active subagent. The match rules are
|
||||
// findSubagentProcess()'s: getClaudePids() skips CODEMAN_MUX=1 processes too.
|
||||
const pidMap = await this.getClaudePids();
|
||||
const signalled = new Set<number>();
|
||||
for (const agent of targets) {
|
||||
// The liveness checker may have completed it while the scan ran.
|
||||
if (agent.status !== 'active' && agent.status !== 'idle') continue;
|
||||
for (const [pid, procInfo] of pidMap) {
|
||||
if (signalled.has(pid)) continue;
|
||||
if (procInfo.environ.includes(agent.sessionId) || procInfo.cmdline.includes(agent.sessionId)) {
|
||||
signalled.add(pid);
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
break; // one process per agent, as killSubagent() does
|
||||
}
|
||||
}
|
||||
this.markSubagentAsCompleted(agent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-24
@@ -94,7 +94,13 @@ import {
|
||||
type DockerMount,
|
||||
type DockerSeedCopy,
|
||||
} from './docker-hosts.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
|
||||
import {
|
||||
wrapWithNice,
|
||||
SAFE_PATH_PATTERN,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
waitForProcessesExit,
|
||||
} from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
MuxSession,
|
||||
@@ -144,12 +150,18 @@ const TMUX_CREATION_WAIT_MS = 100;
|
||||
const GET_PID_MAX_RETRIES = 5;
|
||||
const GET_PID_RETRY_MS = 200;
|
||||
|
||||
/** Delay after tmux kill command (200ms) */
|
||||
/**
|
||||
* How long killSession gives a pane's children to exit on SIGTERM before it
|
||||
* re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
|
||||
*/
|
||||
const TMUX_KILL_WAIT_MS = 200;
|
||||
|
||||
/** Delay for graceful shutdown (100ms) */
|
||||
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
|
||||
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
|
||||
/** How long killSession waits for every process it signalled to be gone before it warns. */
|
||||
const KILL_VERIFY_TIMEOUT_MS = 2000;
|
||||
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
@@ -2350,6 +2362,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link getPanePid} without blocking the event loop, for the kill path: a close
|
||||
* must not stall every other session's I/O while tmux answers.
|
||||
*/
|
||||
private async getPanePidAsync(muxName: string): Promise<number | null> {
|
||||
if (IS_TEST_MODE) return 99999;
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in getPanePidAsync:', muxName);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execAsync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
const pid = parseInt(stdout.trim(), 10);
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tmux session exists.
|
||||
*/
|
||||
@@ -2604,7 +2638,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if a process is still alive
|
||||
// Check if a process is still alive. Signal decisions only: an unreaped zombie
|
||||
// counts here, so its process group still gets the SIGKILL below. The WAITS use
|
||||
// waitForProcessesExit(), which counts a zombie as exited.
|
||||
private isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
@@ -2614,20 +2650,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all PIDs are dead, with retry
|
||||
// Verify all PIDs are dead, returning as soon as they are
|
||||
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
const checkInterval = 100;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
|
||||
if (aliveCount === 0) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
|
||||
const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
|
||||
if (stillAlive.length > 0) {
|
||||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||||
}
|
||||
@@ -2685,7 +2710,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
// Local socket only — detaches the remote session by killing the local ssh pane.
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
@@ -2702,7 +2727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// Get current PID (may have changed)
|
||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||||
const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
|
||||
|
||||
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
||||
|
||||
@@ -2724,7 +2749,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||||
// Most children are gone within a few ms; the re-scan below still runs, to
|
||||
// catch anything spawned since the first one.
|
||||
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
|
||||
|
||||
childPids = await this.getChildPidsFresh(currentPid);
|
||||
for (const childPid of childPids) {
|
||||
@@ -2742,7 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
process.kill(-currentPid, 'SIGTERM');
|
||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
||||
await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
process.kill(-currentPid, 'SIGKILL');
|
||||
}
|
||||
@@ -2751,10 +2778,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
|
||||
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
|
||||
// Async: tmux takes tens of ms to tear a session down, and execSync held the
|
||||
// whole server for that long on every close.
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
@@ -2798,7 +2827,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// Verify all processes are dead
|
||||
const allDead = await this.verifyProcessesDead(allPids, 2000);
|
||||
const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
|
||||
if (!allDead) {
|
||||
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
||||
export { startEventLoopMonitor } from './event-loop-monitor.js';
|
||||
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
|
||||
export { StaleExpirationMap } from './stale-expiration-map.js';
|
||||
export { isProcessRunning, waitForProcessesExit, PROCESS_EXIT_POLL_MS } from './process-exit-wait.js';
|
||||
export {
|
||||
ANSI_ESCAPE_PATTERN_FULL,
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @fileoverview Wait for processes to exit, and return as soon as they have.
|
||||
*
|
||||
* The session kill path used to sleep a FIXED interval after each signal (100 ms
|
||||
* for the PTY client, 200 ms for the pane's children, 100 ms for the process
|
||||
* group) and then verified in 100 ms steps. A process that was gone after 3 ms
|
||||
* still cost the whole interval, so closing a tab spent most of its ~0.5 s in
|
||||
* timers. This keeps every deadline the kill path had; it only stops waiting
|
||||
* once there is nothing left to wait for.
|
||||
*
|
||||
* A zombie counts as exited. It holds nothing but its pid until the parent reaps
|
||||
* it, and on the kill path that parent is the tmux server or the service
|
||||
* manager, which is no reason to hold up a close. `kill(pid, 0)` cannot tell a
|
||||
* zombie from a running process, so on Linux the state letter in
|
||||
* `/proc/<pid>/stat` decides; without procfs `kill(pid, 0)` is the answer.
|
||||
*
|
||||
* @module utils/process-exit-wait
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/** Poll step while waiting: an exit is noticed within about one frame. */
|
||||
export const PROCESS_EXIT_POLL_MS = 10;
|
||||
|
||||
/**
|
||||
* True while `pid` names a process that has not exited. A pid we may not signal
|
||||
* reads as not running, which is what the kill path's own check always did:
|
||||
* there is nothing it could do about such a process anyway.
|
||||
*/
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (process.platform !== 'linux') return true;
|
||||
let stat: string;
|
||||
try {
|
||||
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
} catch (err) {
|
||||
// Gone between the two reads. Any other failure: trust kill(pid, 0).
|
||||
return (err as NodeJS.ErrnoException).code !== 'ENOENT';
|
||||
}
|
||||
// "<pid> (<comm>) <state> …": comm may hold spaces and parentheses itself,
|
||||
// so the state is the field after the LAST ')'.
|
||||
const close = stat.lastIndexOf(')');
|
||||
const state = close === -1 ? '' : stat.charAt(close + 2);
|
||||
return state !== 'Z' && state !== 'X';
|
||||
}
|
||||
|
||||
export interface WaitForExitOptions {
|
||||
/** Give up after this long; the survivors are returned, never thrown. */
|
||||
timeoutMs: number;
|
||||
/** Poll step, {@link PROCESS_EXIT_POLL_MS} by default. */
|
||||
pollMs?: number;
|
||||
/** Liveness probe, {@link isProcessRunning} by default (injectable for tests). */
|
||||
isRunning?: (pid: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once every pid in `pids` has exited, or at the deadline with the ones
|
||||
* that have not. Never rejects.
|
||||
*/
|
||||
export async function waitForProcessesExit(pids: readonly number[], options: WaitForExitOptions): Promise<number[]> {
|
||||
const isRunning = options.isRunning ?? isProcessRunning;
|
||||
const pollMs = Math.max(1, options.pollMs ?? PROCESS_EXIT_POLL_MS);
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
let running = pids.filter((pid) => isRunning(pid));
|
||||
while (running.length > 0) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
|
||||
running = running.filter((pid) => isRunning(pid));
|
||||
}
|
||||
return running;
|
||||
}
|
||||
+72
-20
@@ -2294,6 +2294,8 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_onSessionCreated(data) {
|
||||
// A session this tab is closing stays closed until the server answers.
|
||||
if (this._closingSessions?.has(data.id)) return;
|
||||
this.sessions.set(data.id, data);
|
||||
// Add new session to end of tab order
|
||||
if (!this.sessionOrder.includes(data.id)) {
|
||||
@@ -2318,6 +2320,9 @@ class CodemanApp {
|
||||
|
||||
_onSessionUpdated(data) {
|
||||
const session = data.session || data;
|
||||
// A session this tab is closing stays closed until the server answers
|
||||
// (closeSession() puts it back if the delete is refused).
|
||||
if (this._closingSessions?.has(session.id)) return;
|
||||
const oldSession = this.sessions.get(session.id);
|
||||
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -6115,9 +6120,19 @@ class CodemanApp {
|
||||
// Session Tabs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderSessionTabs() {
|
||||
renderSessionTabs({ immediate = false } = {}) {
|
||||
// Don't re-render while user is typing in the inline rename input
|
||||
if (this._inlineRenameActive) return;
|
||||
if (immediate) {
|
||||
// For a change the user just made and is watching for (closing a tab). The
|
||||
// debounce restarts on every session update, so with busy sessions around a
|
||||
// debounced pass can lag well past its 100ms. A pass still pending would only
|
||||
// repeat this one, so it is dropped.
|
||||
clearTimeout(this._debounceTimers.sessionTabs);
|
||||
this._debounceTimers.sessionTabs = null;
|
||||
this._renderSessionTabsImmediate();
|
||||
return;
|
||||
}
|
||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||
}
|
||||
|
||||
@@ -9824,27 +9839,32 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async closeSession(sessionId, killMux = true) {
|
||||
// ⚠️ Captured BEFORE the await, and the delete is announced to
|
||||
// _onSessionDeleted through _closingSessions. The `session_deleted` SSE
|
||||
// broadcast for THIS delete routinely lands while the request is still in
|
||||
// flight, and that handler nulls activeSessionId and shows the welcome
|
||||
// screen. Re-reading the field after the await therefore made the fallback
|
||||
// below a coin flip: closing the tab you were on either moved you to the
|
||||
// next session or dumped you on the home screen, depending on which path
|
||||
// won the race (both outcomes measured on one build, 2026-08-17).
|
||||
// Already on its way out (a repeated click, the mux panel racing the tab).
|
||||
if (this._closingSessions.has(sessionId)) return;
|
||||
// The tab goes FIRST and the server is asked after. The kill takes the server
|
||||
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
|
||||
// there that long after "Kill" read as a click that did nothing. A refused
|
||||
// delete puts the row back below.
|
||||
//
|
||||
// ⚠️ Everything is read BEFORE the first await, and the delete is announced
|
||||
// to _onSessionDeleted through _closingSessions: the `session_deleted` SSE
|
||||
// broadcast for THIS delete arrives while the request is still in flight, and
|
||||
// that handler must leave the follow-up selection to this method (both
|
||||
// outcomes of that race were measured on one build, 2026-08-17).
|
||||
const session = this.sessions.get(sessionId);
|
||||
const orderIndex = this.sessionOrder.indexOf(sessionId);
|
||||
const wasActive = this.activeSessionId === sessionId;
|
||||
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
|
||||
// sessionOrder entry (often not tiled, which would collapse the grid).
|
||||
// Captured here for the same reason as wasActive: the SSE delete can remove
|
||||
// the tile while the request is still in flight.
|
||||
const grid = this._tileGrid;
|
||||
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
|
||||
this._closingSessions.add(sessionId);
|
||||
let res = null;
|
||||
try {
|
||||
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
||||
this._cleanupSessionData(sessionId);
|
||||
// The last tile leaving closes the grid (no reselect): the pick below runs.
|
||||
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
|
||||
// The same teardown the SSE event runs (split pane, tile, detached window,
|
||||
// WebSocket, per-session state), only now instead of when the server is
|
||||
// done. It is idempotent, so the real event finds nothing left to do.
|
||||
this._onSessionDeleted({ id: sessionId });
|
||||
|
||||
if (wasActive && grid?.open) {
|
||||
// `auto`: the app chose this tile because the previous one went away.
|
||||
@@ -9869,18 +9889,50 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
this.renderSessionTabs();
|
||||
this.renderSessionTabs({ immediate: true });
|
||||
|
||||
res = await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
||||
} catch (err) {
|
||||
// `res` stays null: handled below like a delete that got no answer.
|
||||
console.warn('[closeSession] close failed:', err);
|
||||
} finally {
|
||||
this._closingSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
// 404: already gone (closed from another tab or device), which is what was asked.
|
||||
let gone = !!res && (res.ok || res.status === 404);
|
||||
if (!gone) {
|
||||
// Refused, or no answer. Ask rather than guess: a delete can land on the
|
||||
// server and still lose its reply, and its session_deleted event has then
|
||||
// already been spent while the request was in flight.
|
||||
const check = await this._api(`/api/sessions/${sessionId}`);
|
||||
gone = check?.status === 404;
|
||||
}
|
||||
|
||||
if (gone) {
|
||||
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
|
||||
// the server, which still had the session then.
|
||||
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
|
||||
if (killMux) {
|
||||
this.showToast('Session closed and tmux killed', 'success');
|
||||
} else {
|
||||
this.showToast('Tab hidden, tmux still running', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to close session', 'error');
|
||||
} finally {
|
||||
this._closingSessions.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Still on the server (or the server is unreachable, in which case the resync
|
||||
// on reconnect has the last word): its row comes back where it was.
|
||||
if (session && !this.sessions.has(sessionId)) {
|
||||
this.sessions.set(sessionId, session);
|
||||
if (!this.sessionOrder.includes(sessionId)) {
|
||||
const at = orderIndex === -1 ? this.sessionOrder.length : Math.min(orderIndex, this.sessionOrder.length);
|
||||
this.sessionOrder.splice(at, 0, sessionId);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
this.renderSessionTabs();
|
||||
}
|
||||
this.showToast('Failed to close session', 'error');
|
||||
}
|
||||
|
||||
// Request confirmation before closing a session
|
||||
|
||||
Reference in new issue
Block a user