feat: update sidebar agent workflow and host filter UI

This commit is contained in:
Mohamed Boudra
2026-02-15 08:59:17 +07:00
parent 9da5b26db0
commit 41d1fa6d97
31 changed files with 2482 additions and 1133 deletions
@@ -1,6 +1,21 @@
import { describe, it, expect, afterEach } from "vitest";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
async function waitForCondition(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 25
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
describe("TerminalManager", () => {
let manager: TerminalManager;
@@ -136,6 +151,21 @@ describe("TerminalManager", () => {
manager = createTerminalManager();
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
});
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const exitedId = terminals[0].id;
terminals[0].send({ type: "input", data: "\u0004" });
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(1);
expect(remaining[0].id).not.toBe(exitedId);
});
});
describe("listDirectories", () => {
@@ -161,12 +191,14 @@ describe("TerminalManager", () => {
manager = createTerminalManager();
const tmpTerminals = await manager.getTerminals("/tmp");
const homeTerminals = await manager.getTerminals("/home");
const tmpId = tmpTerminals[0].id;
const homeId = homeTerminals[0].id;
manager.killAll();
expect(manager.listDirectories()).toEqual([]);
expect(manager.getTerminal(tmpTerminals[0].id)).toBeUndefined();
expect(manager.getTerminal(homeTerminals[0].id)).toBeUndefined();
expect(manager.getTerminal(tmpId)).toBeUndefined();
expect(manager.getTerminal(homeId)).toBeUndefined();
});
});
});
@@ -12,6 +12,7 @@ export interface TerminalManager {
export function createTerminalManager(): TerminalManager {
const terminalsByCwd = new Map<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => void>();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
@@ -19,16 +20,56 @@ export function createTerminalManager(): TerminalManager {
}
}
function removeSessionById(id: string, options: { kill: boolean }): void {
const session = terminalsById.get(id);
if (!session) {
return;
}
const unsubscribeExit = terminalExitUnsubscribeById.get(id);
if (unsubscribeExit) {
unsubscribeExit();
terminalExitUnsubscribeById.delete(id);
}
terminalsById.delete(id);
const terminals = terminalsByCwd.get(session.cwd);
if (terminals) {
const index = terminals.findIndex((terminal) => terminal.id === id);
if (index !== -1) {
terminals.splice(index, 1);
}
if (terminals.length === 0) {
terminalsByCwd.delete(session.cwd);
}
}
if (options.kill) {
session.kill();
}
}
function registerSession(session: TerminalSession): TerminalSession {
terminalsById.set(session.id, session);
const unsubscribeExit = session.onExit(() => {
removeSessionById(session.id, { kill: false });
});
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
return session;
}
return {
async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd);
let terminals = terminalsByCwd.get(cwd);
if (!terminals || terminals.length === 0) {
const session = await createTerminal({ cwd, name: "Terminal 1" });
const session = registerSession(
await createTerminal({ cwd, name: "Terminal 1" })
);
terminals = [session];
terminalsByCwd.set(cwd, terminals);
terminalsById.set(session.id, session);
}
return terminals;
},
@@ -38,14 +79,15 @@ export function createTerminalManager(): TerminalManager {
const terminals = terminalsByCwd.get(options.cwd) ?? [];
const defaultName = `Terminal ${terminals.length + 1}`;
const session = await createTerminal({
cwd: options.cwd,
name: options.name ?? defaultName,
});
const session = registerSession(
await createTerminal({
cwd: options.cwd,
name: options.name ?? defaultName,
})
);
terminals.push(session);
terminalsByCwd.set(options.cwd, terminals);
terminalsById.set(session.id, session);
return session;
},
@@ -55,22 +97,7 @@ export function createTerminalManager(): TerminalManager {
},
killTerminal(id: string): void {
const session = terminalsById.get(id);
if (!session) return;
session.kill();
terminalsById.delete(id);
const terminals = terminalsByCwd.get(session.cwd);
if (terminals) {
const index = terminals.indexOf(session);
if (index !== -1) {
terminals.splice(index, 1);
}
if (terminals.length === 0) {
terminalsByCwd.delete(session.cwd);
}
}
removeSessionById(id, { kill: true });
},
listDirectories(): string[] {
@@ -78,11 +105,9 @@ export function createTerminalManager(): TerminalManager {
},
killAll(): void {
for (const session of terminalsById.values()) {
session.kill();
for (const id of Array.from(terminalsById.keys())) {
removeSessionById(id, { kill: true });
}
terminalsByCwd.clear();
terminalsById.clear();
},
};
}
+57 -5
View File
@@ -64,6 +64,7 @@ export interface TerminalSession {
cwd: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: () => void): () => void;
subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number }
@@ -190,6 +191,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const id = randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
const exitListeners = new Set<() => void>();
const rawOutputChunks: Array<{
startOffset: number;
endOffset: number;
@@ -199,6 +201,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
let rawBufferBytes = 0;
let outputOffset = 0;
let killed = false;
let disposed = false;
let exitEmitted = false;
// Create xterm.js headless terminal
const terminal = new Terminal({
@@ -221,6 +225,32 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
},
});
function emitExit(): void {
if (exitEmitted) {
return;
}
exitEmitted = true;
for (const listener of Array.from(exitListeners)) {
try {
listener();
} catch {
// no-op
}
}
exitListeners.clear();
}
function disposeResources(): void {
if (disposed) {
return;
}
disposed = true;
terminal.dispose();
listeners.clear();
rawListeners.clear();
exitListeners.clear();
}
function appendRawOutputChunk(data: string): void {
const chunkBytes = Buffer.byteLength(data);
if (chunkBytes <= 0) {
@@ -292,6 +322,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ptyProcess.onExit(() => {
killed = true;
emitExit();
disposeResources();
});
function getState(): TerminalState {
@@ -340,6 +372,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function onExit(listener: () => void): () => void {
if (killed) {
queueMicrotask(() => {
try {
listener();
} catch {
// no-op
}
});
return () => {};
}
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
}
function subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number }
@@ -380,11 +430,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
function kill(): void {
if (killed) return;
killed = true;
ptyProcess.kill();
terminal.dispose();
rawListeners.clear();
if (!killed) {
killed = true;
ptyProcess.kill();
emitExit();
}
disposeResources();
}
// Small delay to let shell initialize
@@ -396,6 +447,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
cwd,
send,
subscribe,
onExit,
subscribeRaw,
getOutputOffset,
getState,