Compare commits

...
Author SHA1 Message Date
netcatty-bot 9e76b33e32 fix(#3550): automated AI fix 2026-09-29 07:15:39 +00:00
4 changed files with 223 additions and 0 deletions

No files matched your search

+16
View File
@@ -66,6 +66,8 @@ import {
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
import { useVaultSnapshotField } from './vaultSnapshotStore';
export function addWorkspaceIfMissing(
workspaces: Workspace[],
@@ -267,6 +269,20 @@ export const useSessionState = ({
}
}, [initialRestoreState.activeTabId]);
// Keep open sessions' connection label in sync with the vault. Sessions
// snapshot `hostLabel` at connect time, so renaming a host would otherwise
// leave its already-open tabs (and merged-workspace titles derived from
// those labels) showing the old name until the session is closed. The
// generated-title sync effect below re-runs on this session change and
// refreshes synthesized workspace titles too.
const vaultHosts = useVaultSnapshotField('hosts');
useEffect(() => {
if (vaultHosts.length === 0) return;
// The helper returns the same array reference when nothing changed, so
// React bails out of the state update.
setSessions(prev => syncSessionHostLabels(prev, vaultHosts));
}, [vaultHosts]);
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
// "02" onto "01") synchronized with the workspace's current membership and
// each pane's connection label. `autoTitle: false` makes
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { Host } from "../../domain/models";
import { EMPTY_VAULT_SNAPSHOT, publishVaultSnapshot } from "./vaultSnapshotStore";
import { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
const testHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "web-01",
hostname: "10.0.0.1",
port: 22,
username: "root",
os: "linux",
tags: [],
...overrides,
} as Host);
test("rewriting the host label updates already-open session tabs on that host", async (t) => {
publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT);
t.after(() => publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT));
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
let sessionId = "";
await act(async () => {
sessionId = current().connectToHost(testHost());
});
const sessionOf = () => current().sessions.find((s) => s.id === sessionId);
assert.ok(sessionOf());
assert.equal(sessionOf()?.hostLabel, "web-01");
// The host is renamed in the vault while the session stays open.
await act(async () => {
publishVaultSnapshot({
...EMPTY_VAULT_SNAPSHOT,
hosts: [testHost({ label: "web-01-renamed" })],
});
});
assert.equal(sessionOf()?.hostLabel, "web-01-renamed");
// Renaming the pane afterwards hands the title to the user rename.
await act(async () => {
current().submitSessionRename(sessionId, "my pane");
});
const renamed = sessionOf();
assert.ok(renamed);
assert.equal(renamed!.customName, "my pane");
// A later vault rename must not clobber the user-authored pane name.
await act(async () => {
publishVaultSnapshot({
...EMPTY_VAULT_SNAPSHOT,
hosts: [testHost({ label: "web-01-again" })],
});
});
const after = sessionOf();
assert.ok(after);
assert.equal(after!.customName, "my pane");
await act(async () => {
renderer?.unmount();
});
});
+64
View File
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TerminalSession } from "./models";
import { syncSessionHostLabels } from "./syncSessionHostLabels";
const session = (overrides: Partial<TerminalSession>): TerminalSession => ({
id: "s1",
hostId: "h1",
hostLabel: "old-label",
hostname: "10.0.0.1",
username: "root",
status: "connected",
...overrides,
} as TerminalSession);
test("syncSessionHostLabels mirrors the vault host label onto open sessions", () => {
const sessions = [session({ id: "s1", hostId: "h1", hostLabel: "old" })];
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "web-01" }]);
assert.equal(next[0].hostLabel, "web-01");
assert.notEqual(next, sessions);
});
test("syncSessionHostLabels returns the same array when labels already match", () => {
const sessions = [session({ hostLabel: "web-01" })];
assert.equal(syncSessionHostLabels(sessions, [{ id: "h1", label: "web-01" }]), sessions);
});
test("syncSessionHostLabels leaves sessions without a matching vault host untouched", () => {
const sessions = [
session({ id: "local", hostId: "local-terminal", hostLabel: "Local Terminal" }),
session({ id: "serial", hostId: "serial-abc", hostLabel: "Serial: ttyUSB0" }),
session({ id: "eph", hostId: "ephemeral-host", hostLabel: "Quick Connect" }),
];
const next = syncSessionHostLabels(sessions, [{ id: "h9", label: "other" }]);
assert.equal(next, sessions);
});
test("syncSessionHostLabels keeps a renamed pane's custom name authoritative", () => {
const sessions = [session({ customName: "my pane", hostLabel: "old" })];
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "web-01" }]);
assert.equal(next, sessions);
});
test("syncSessionHostLabels updates only hosts present in the vault", () => {
const sessions = [
session({ id: "s1", hostId: "h1", hostLabel: "old-a" }),
session({ id: "s2", hostId: "h2", hostLabel: "old-b" }),
];
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "new-a" }]);
assert.equal(next[0].hostLabel, "new-a");
assert.equal(next[1].hostLabel, "old-b");
});
test("syncSessionHostLabels preserves unknown labels for hosts with empty labels", () => {
const sessions = [session({ hostId: "h1", hostLabel: "Serial: ttyS0" })];
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "" }]);
assert.equal(next, sessions);
});
test("syncSessionHostLabels is a no-op without hosts", () => {
const sessions = [session({})];
assert.equal(syncSessionHostLabels(sessions, []), sessions);
});
+40
View File
@@ -0,0 +1,40 @@
import type { Host, TerminalSession } from './models';
/**
* Mirror vault host labels onto open terminal sessions.
*
* Session records snapshot the host label at connect time (`hostLabel`), so a
* host renamed in the vault leaves already-open session tabs showing the old
* name until the session is closed and reopened. Pure helper: returns the same
* array reference when nothing needs to change, so React state updaters bail
* out without triggering a re-render.
*
* Rules per session:
* - Only sessions whose `hostId` matches a vault host are touched. Local
* terminals and standalone serial-port sessions use a synthetic `hostId`,
* and ephemeral/deep-link hosts are absent from the vault, so their snapshot
* label (e.g. the shell name, "Serial: …") is preserved.
* - A user rename of the pane (`customName`) wins over the host label in every
* consumer, so renamed panes are left untouched; their `hostLabel` is only
* an echo of the rename.
*/
export const syncSessionHostLabels = (
sessions: readonly TerminalSession[],
hosts: readonly Pick<Host, 'id' | 'label'>[],
): TerminalSession[] => {
if (hosts.length === 0) return sessions as TerminalSession[];
const labelById = new Map<string, string>();
for (const host of hosts) {
if (host.id && host.label) labelById.set(host.id, host.label);
}
if (labelById.size === 0) return sessions as TerminalSession[];
let changed = false;
const next = sessions.map((session) => {
if (session.customName?.trim()) return session;
const label = session.hostId ? labelById.get(session.hostId) : undefined;
if (label === undefined || session.hostLabel === label) return session;
changed = true;
return { ...session, hostLabel: label };
});
return changed ? next : (sessions as TerminalSession[]);
};