mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Merge pull request #180 from Lint111/agent/split-preserve-active-launch
fix(sessions): preserve active terminal during launches
This commit is contained in:
@@ -582,13 +582,43 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return startNumber;
|
return startNumber;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch progress may use the terminal only on the session-less home screen.
|
||||||
|
* When another session is active, mutating the shared xterm would serialize
|
||||||
|
* launch chrome into that session's snapshot during the subsequent switch.
|
||||||
|
*/
|
||||||
|
_beginSessionLaunchStatus(message, ansiColor = '1;32') {
|
||||||
|
const ownsTerminal = !this.activeSessionId;
|
||||||
|
if (ownsTerminal) {
|
||||||
|
this.terminal.clear();
|
||||||
|
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
||||||
|
this.terminal.writeln('');
|
||||||
|
} else {
|
||||||
|
this.showToast?.(message, 'info');
|
||||||
|
}
|
||||||
|
return ownsTerminal;
|
||||||
|
},
|
||||||
|
|
||||||
|
_appendSessionLaunchStatus(ownsTerminal, message, ansiColor = '90') {
|
||||||
|
if (!ownsTerminal || this.activeSessionId) return;
|
||||||
|
this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`);
|
||||||
|
},
|
||||||
|
|
||||||
|
_reportSessionLaunchError(ownsTerminal, message) {
|
||||||
|
if (ownsTerminal && !this.activeSessionId) {
|
||||||
|
this.terminal.writeln(`\x1b[1;31m Error: ${message}\x1b[0m`);
|
||||||
|
} else {
|
||||||
|
this.showToast?.(message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async runClaude() {
|
async runClaude() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||||
|
|
||||||
this.terminal.clear();
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
`Starting ${tabCount} Claude session(s) in ${caseName}...`
|
||||||
this.terminal.writeln('');
|
);
|
||||||
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
// Focus terminal NOW, in the synchronous user-gesture context (button click).
|
||||||
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
// iOS Safari ignores programmatic focus() after any await, so this must happen
|
||||||
// before the first async call. The keyboard opens here and stays open through
|
// before the first async call. The keyboard opens here and stays open through
|
||||||
@@ -671,7 +701,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||||
remoteIds.push(data.data.sessionId);
|
remoteIds.push(data.data.sessionId);
|
||||||
}
|
}
|
||||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
|
||||||
if (remoteIds[0]) {
|
if (remoteIds[0]) {
|
||||||
await this.selectSession(remoteIds[0]);
|
await this.selectSession(remoteIds[0]);
|
||||||
this.loadQuickStartCases();
|
this.loadQuickStartCases();
|
||||||
@@ -706,7 +736,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
|
const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : '');
|
||||||
|
|
||||||
// Step 1: Create all sessions in parallel
|
// Step 1: Create all sessions in parallel
|
||||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Creating ${tabCount} session(s)...`);
|
||||||
const createPromises = sessionNames.map(name =>
|
const createPromises = sessionNames.map(name =>
|
||||||
fetch('/api/sessions', {
|
fetch('/api/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -747,12 +777,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
));
|
));
|
||||||
|
|
||||||
// Step 3: Start all sessions in parallel (biggest speedup)
|
// Step 3: Start all sessions in parallel (biggest speedup)
|
||||||
this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`);
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} session(s) in parallel...`);
|
||||||
await Promise.all(sessionIds.map(id =>
|
await Promise.all(sessionIds.map(id =>
|
||||||
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
|
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
|
||||||
));
|
));
|
||||||
|
|
||||||
this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`);
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} sessions ready`);
|
||||||
|
|
||||||
// Auto-switch to the new session using selectSession (does proper refresh)
|
// Auto-switch to the new session using selectSession (does proper refresh)
|
||||||
if (firstSessionId) {
|
if (firstSessionId) {
|
||||||
@@ -762,7 +792,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -805,9 +835,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
|
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
|
||||||
|
|
||||||
this.terminal.clear();
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||||
this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`);
|
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
|
||||||
this.terminal.writeln('');
|
'1;33'
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the case path
|
// Get the case path
|
||||||
@@ -913,7 +944,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -924,9 +955,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||||
|
|
||||||
this.terminal.clear();
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`);
|
||||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
|
||||||
this.terminal.writeln('');
|
|
||||||
// Focus in sync gesture context (see runClaude comment)
|
// Focus in sync gesture context (see runClaude comment)
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
|
|
||||||
@@ -936,8 +965,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const statusRes = await fetch('/api/opencode/status');
|
const statusRes = await fetch('/api/opencode/status');
|
||||||
const status = (await statusRes.json()).data;
|
const status = (await statusRes.json()).data;
|
||||||
if (!status.available) {
|
if (!status.available) {
|
||||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
this._reportSessionLaunchError(
|
||||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
ownsLaunchTerminal,
|
||||||
|
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -970,7 +1001,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -981,9 +1012,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||||
|
|
||||||
this.terminal.clear();
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`);
|
||||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
|
||||||
this.terminal.writeln('');
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -991,8 +1020,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const statusRes = await fetch('/api/codex/status');
|
const statusRes = await fetch('/api/codex/status');
|
||||||
const status = (await statusRes.json()).data;
|
const status = (await statusRes.json()).data;
|
||||||
if (!status.available) {
|
if (!status.available) {
|
||||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
this._reportSessionLaunchError(
|
||||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
ownsLaunchTerminal,
|
||||||
|
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1058,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1038,9 +1069,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||||
|
|
||||||
this.terminal.clear();
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`);
|
||||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
|
||||||
this.terminal.writeln('');
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1048,8 +1077,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const statusRes = await fetch('/api/gemini/status');
|
const statusRes = await fetch('/api/gemini/status');
|
||||||
const status = (await statusRes.json()).data;
|
const status = (await statusRes.json()).data;
|
||||||
if (!status.available) {
|
if (!status.available) {
|
||||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
this._reportSessionLaunchError(
|
||||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
ownsLaunchTerminal,
|
||||||
|
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1078,7 +1109,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1089,9 +1120,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||||
|
|
||||||
this.terminal.clear();
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`);
|
||||||
this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`);
|
|
||||||
this.terminal.writeln('');
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1099,8 +1128,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const statusRes = await fetch('/api/antigravity/status');
|
const statusRes = await fetch('/api/antigravity/status');
|
||||||
const status = (await statusRes.json()).data;
|
const status = (await statusRes.json()).data;
|
||||||
if (!status.available) {
|
if (!status.available) {
|
||||||
this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m');
|
this._reportSessionLaunchError(
|
||||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m');
|
ownsLaunchTerminal,
|
||||||
|
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1129,7 +1160,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,101 @@ describe('run mode UI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Run launch synchronization', () => {
|
describe('Run launch synchronization', () => {
|
||||||
|
it('keeps launch progress out of an active session terminal', () => {
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
document: { getElementById: () => null },
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||||
|
|
||||||
|
const app = new (CodemanApp as any)();
|
||||||
|
app.activeSessionId = 'existing-session';
|
||||||
|
app.terminal = {
|
||||||
|
clear: vi.fn(),
|
||||||
|
writeln: vi.fn(),
|
||||||
|
};
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
|
||||||
|
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
||||||
|
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
||||||
|
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
||||||
|
|
||||||
|
expect(ownsTerminal).toBe(false);
|
||||||
|
expect(app.terminal.clear).not.toHaveBeenCalled();
|
||||||
|
expect(app.terminal.writeln).not.toHaveBeenCalled();
|
||||||
|
expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info');
|
||||||
|
expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders launch progress in the terminal on the session-less home screen', () => {
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
localStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
document: { getElementById: () => null },
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||||
|
|
||||||
|
const app = new (CodemanApp as any)();
|
||||||
|
app.activeSessionId = null; // home screen: nothing else owns the terminal
|
||||||
|
app.terminal = { clear: vi.fn(), writeln: vi.fn() };
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
|
||||||
|
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
|
||||||
|
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
|
||||||
|
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
|
||||||
|
|
||||||
|
expect(ownsTerminal).toBe(true);
|
||||||
|
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session');
|
||||||
|
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session');
|
||||||
|
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed');
|
||||||
|
expect(app.showToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static guard over session-ui.js itself. The helpers above can be perfectly
|
||||||
|
* correct while a run*() entry point still writes to the shared xterm
|
||||||
|
* directly, which is the actual bug: a launch started while another session
|
||||||
|
* is active wipes that session's terminal, and _cleanupPreviousSession()
|
||||||
|
* then serializes the wiped view into its restore snapshot. Asserting on the
|
||||||
|
* helpers alone cannot see that, so pin the call sites here. This also
|
||||||
|
* covers run modes added later, which is how runAntigravity was caught.
|
||||||
|
*/
|
||||||
|
it('routes every run mode through the ownership helpers, never the terminal directly', () => {
|
||||||
|
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||||
|
|
||||||
|
// Methods live in one Object.assign(prototype, {...}) block at a fixed
|
||||||
|
// 2-space indent, so `\n },` reliably closes the one we are inside.
|
||||||
|
const bodies = new Map<string, string>();
|
||||||
|
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
|
||||||
|
for (let m = header.exec(src); m; m = header.exec(src)) {
|
||||||
|
const start = m.index + m[0].length;
|
||||||
|
const end = src.indexOf('\n },', start);
|
||||||
|
expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start);
|
||||||
|
bodies.set(m[1], src.slice(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail loudly if the scan matched nothing: a silently empty scan would make
|
||||||
|
// every assertion below vacuously true.
|
||||||
|
expect([...bodies.keys()]).toEqual(
|
||||||
|
expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity'])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, body] of bodies) {
|
||||||
|
expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear(');
|
||||||
|
expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain(
|
||||||
|
'this.terminal.writeln('
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
|
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
|
||||||
const runBtn = {
|
const runBtn = {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user