mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(tabs): start a session beside any tab from an opt-in + in its actions
Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case. The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons. The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well.
This commit is contained in:
1 parent
3e06f3c141
commit
c7835a159c
18 files changed
+1013
-45
No files matched your search
@@ -162,6 +162,17 @@ describe('phone tab tap zones', () => {
|
||||
expect(phoneDeclarations('.session-tab .tab-gear').display).toBe('none');
|
||||
});
|
||||
|
||||
it('with the opt-in "+" switched on, the wider reserve clears all three icons', () => {
|
||||
// showTabNewButton adds a third icon to the active phone tab (html.tabs-show-new)
|
||||
// and a larger reserve to go with it; the same arithmetic must still hold.
|
||||
const plus = phoneDeclarations('html.tabs-show-new .session-tab.active .tab-new');
|
||||
const wide = len(phoneDeclarations('html.tabs-show-new .session-tab.active .tab-name')['min-width']);
|
||||
expect(len(plus.width)).toBeGreaterThanOrEqual(28);
|
||||
expect(wide).toBeGreaterThanOrEqual(requiredReserve() + len(plus.width) + ROUNDING_ALLOWANCE);
|
||||
expect(wide).toBeLessThanOrEqual(len(nameShared['max-width']));
|
||||
expect(phoneDeclarations('.session-tab .tab-new').display).toBe('none');
|
||||
});
|
||||
|
||||
it('the pop-out icon stays out of the cluster on handhelds', () => {
|
||||
// `icons` above counts gear + close only. If detach ever became visible on a
|
||||
// phone the reserve would be ~30px short and the centre would walk back.
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
* read and write it, and the create routes that carry the case into the real
|
||||
* TabLayoutService so a new session lands in its case's group in the creating
|
||||
* write (POST /api/sessions, and POST /api/quick-start for local, remote and
|
||||
* Docker cases).
|
||||
* Docker cases). The tab "+" picker (app.js) is driven end to end against the
|
||||
* same routes, since an ungrouped tab relies on this fallback by design.
|
||||
*
|
||||
* Uses app.inject(), so no real HTTP port is needed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { homedir } from 'node:os';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
@@ -101,6 +104,56 @@ const unwrap = (raw: string) => {
|
||||
return parsed.data ?? parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* The shipping frontend in JSDOM, with fetch answered by the route harnesses, so
|
||||
* a click in the tab "+" picker reaches the real create route and layout service.
|
||||
*/
|
||||
async function bootTabApp(route: (url: string, init?: { method?: string; body?: string }) => Promise<unknown>) {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const win = dom.window as any;
|
||||
win.setInterval = () => 0;
|
||||
win.requestAnimationFrame = () => 0;
|
||||
win.__codemanCliCatalog = [{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true }];
|
||||
const publicDir = join(process.cwd(), 'src/web/public');
|
||||
win.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
['constants.js', 'app.js', 'mobile-overview.js', 'session-ui.js']
|
||||
.map((file) => readFileSync(join(publicDir, file), 'utf8'))
|
||||
.join('\n;\n') +
|
||||
'\n;window.__CdgCodemanApp = CodemanApp;'
|
||||
);
|
||||
win.fetch = async (url: string, init?: { method?: string; body?: string }) => {
|
||||
const body = await route(url, init);
|
||||
return { ok: true, status: 200, json: async () => body };
|
||||
};
|
||||
win.document.body.innerHTML = '<div id="sessionTabs"></div><select id="quickStartCase"></select>';
|
||||
const app = Object.create(win.__CdgCodemanApp.prototype);
|
||||
Object.assign(app, {
|
||||
$: (id: string) => win.document.getElementById(id),
|
||||
sessions: new Map(),
|
||||
activeSessionId: 'tab-1',
|
||||
terminal: { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 },
|
||||
_runMinLockMs: 0,
|
||||
_runMode: 'claude',
|
||||
getTerminalDimensions: () => null,
|
||||
selectSession: async () => {},
|
||||
loadQuickStartCases: () => {},
|
||||
showToast: vi.fn(),
|
||||
_applyTabLayout: () => {},
|
||||
_onSessionCreated: () => {},
|
||||
_renderSessionTabsImmediate: () => {},
|
||||
});
|
||||
return { app, win };
|
||||
}
|
||||
|
||||
describe('case default group: create routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let world: World;
|
||||
@@ -280,6 +333,63 @@ describe('case default group: create routes', () => {
|
||||
// The route's own error path ran cleanupSession for the registered id.
|
||||
expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('from a tab "+" in the browser', () => {
|
||||
/** Opens the tab's "+" picker, picks Shell, and returns the create request and the new session's group. */
|
||||
async function launchBeside(tabGroupId: string | null) {
|
||||
const cases = await createRouteTestHarness(registerCaseRoutes);
|
||||
const creates: Array<Record<string, unknown>> = [];
|
||||
try {
|
||||
const { app, win } = await bootTabApp(async (url, init) => {
|
||||
const payload = init?.body ? JSON.parse(init.body) : undefined;
|
||||
if (url === '/api/sessions' && init?.method === 'POST') creates.push(payload);
|
||||
const target = url.startsWith('/api/cases/') ? cases : harness;
|
||||
const res = await target.app.inject({ method: (init?.method ?? 'GET') as 'GET', url, payload });
|
||||
// The envelope server.ts's preSerialization hook adds, which the harness does not install.
|
||||
const body = res.body ? JSON.parse(res.body) : {};
|
||||
return typeof body.success === 'boolean' ? body : { success: true, data: body };
|
||||
});
|
||||
app.cases = [{ name: CASE, path: caseDir }];
|
||||
app.sessions.set('tab-1', { id: 'tab-1', name: 'w1-cdg', workingDir: caseDir });
|
||||
app.tabLayout = {
|
||||
...world.layouts[OWNER],
|
||||
groups: world.layouts[OWNER].groups.map((g) => ({
|
||||
...g,
|
||||
refs: g.id === tabGroupId ? [{ kind: 'session', id: 'tab-1' }] : [],
|
||||
})),
|
||||
};
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app.toggleTabGroupCollapsed = () => {};
|
||||
const plus = win.document.createElement('span');
|
||||
win.document.body.appendChild(plus);
|
||||
app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus }, 'tab-1');
|
||||
const shell = [...win.document.querySelectorAll('[aria-label="New session here"] button')].find(
|
||||
(button: Element) => button.textContent === 'Terminal / Shell'
|
||||
) as HTMLElement;
|
||||
shell.click();
|
||||
await vi.waitFor(() => expect(creates).toHaveLength(1));
|
||||
await vi.waitFor(() => expect(harness.ctx.sessions.size).toBe(1));
|
||||
const [id] = [...harness.ctx.sessions.keys()];
|
||||
return { create: creates[0], group: groupOf(world.layouts[OWNER], id), toasts: app.showToast.mock.calls };
|
||||
} finally {
|
||||
await cases.app.close();
|
||||
}
|
||||
}
|
||||
|
||||
it('an ungrouped tab sends no group, so the case default group places the session (chosen on purpose)', async () => {
|
||||
const { create, group, toasts } = await launchBeside(null);
|
||||
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}` });
|
||||
expect(group).toBe('g-ops');
|
||||
// The launch went through: no error reported along the way.
|
||||
expect(toasts.filter(([, kind]: [string, string]) => kind === 'error')).toEqual([]);
|
||||
});
|
||||
|
||||
it('a grouped tab sends its own group, which wins over the case default', async () => {
|
||||
const { create, group } = await launchBeside('g-core');
|
||||
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}`, tabGroupId: 'g-core' });
|
||||
expect(group).toBe('g-core');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('case default group: GET/PUT /api/cases/:name/default-group', () => {
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* /api/sessions, remote/docker and external-CLI /api/quick-start), that the
|
||||
* one-session count overrides the toolbar steppers without touching them, that
|
||||
* the layout returned by the create response is adopted BEFORE the new tab is
|
||||
* drawn (so it is drawn inside its group), and that an ordinary Run sends no
|
||||
* drawn (so it is drawn inside its group), that a tab's "+" (`caseName`) starts
|
||||
* in that case rather than the toolbar's, and that an ordinary Run sends no
|
||||
* group at all.
|
||||
*
|
||||
* Port: none (vm sandbox, stubbed fetch).
|
||||
@@ -36,7 +37,7 @@ function harness(caseData: Record<string, unknown>, extra: { mode?: string } = {
|
||||
const body = init?.body ? JSON.parse(init.body) : undefined;
|
||||
requests.push({ url, body });
|
||||
const json = (value: unknown) => ({ json: async () => value });
|
||||
if (url === '/api/cases/proj') return json({ success: true, data: caseData });
|
||||
if (url.startsWith('/api/cases/')) return json({ success: true, data: caseData });
|
||||
if (url.endsWith('/status')) return json({ success: true, data: { available: true, runnable: true } });
|
||||
if (url === '/api/sessions') {
|
||||
const id = `sess-${++counter}`;
|
||||
@@ -124,6 +125,25 @@ describe('launching into a tab group', () => {
|
||||
expect(app._applyTabLayout).toHaveBeenCalledWith(LAYOUT);
|
||||
});
|
||||
|
||||
// A tab's "+" launches beside that tab: its case, never the toolbar's selected one.
|
||||
it.each([
|
||||
// Claude's create body names no mode: it is the server's default.
|
||||
['local Claude', 'claude', '/api/sessions', { workingDir: '/tmp/other' }],
|
||||
['local Shell', 'shell', '/api/sessions', { mode: 'shell', workingDir: '/tmp/other' }],
|
||||
['an external CLI', 'gemini', '/api/quick-start', { mode: 'gemini', caseName: 'other' }],
|
||||
])('%s launches in `caseName` instead of the toolbar case', async (_label, mode, url, where) => {
|
||||
const { app, elements, requests } = harness({ name: 'other', path: '/tmp/other' }, { mode });
|
||||
await app.run({ count: 1, caseName: 'other' });
|
||||
const made = creates(requests);
|
||||
expect(made).toHaveLength(1);
|
||||
expect(made[0].url).toBe(url);
|
||||
expect(made[0].body).toMatchObject(where);
|
||||
expect(JSON.stringify(made[0].body)).not.toContain('proj');
|
||||
// The local paths look the case up by the given name, never the toolbar's.
|
||||
if (url === '/api/sessions') expect(requests[0].url).toBe('/api/cases/other');
|
||||
expect(elements.quickStartCase.value).toBe('proj');
|
||||
});
|
||||
|
||||
it('an ordinary Run sends no group and keeps the stepper count', async () => {
|
||||
const { app, requests } = harness({ name: 'proj', path: '/tmp/proj' });
|
||||
await app.run();
|
||||
|
||||
@@ -910,6 +910,22 @@ describe('group menu', () => {
|
||||
expect(app._tabGroupMenuKeydown).toBeNull();
|
||||
});
|
||||
|
||||
it("Safari's item click (a blur with nowhere to go) keeps the menu open, so the item still runs", () => {
|
||||
// Safari never focuses a clicked button: the press blurs the focused item
|
||||
// with relatedTarget null, and closing on that dropped the click.
|
||||
installFetch();
|
||||
const app = makeApp();
|
||||
open(app);
|
||||
app.startTabGroupRename = vi.fn();
|
||||
const buttons = document.querySelectorAll<HTMLElement>('.tab-layout-group-action-menu button');
|
||||
const rename = [...buttons].find((button) => button.textContent === 'Rename group')!;
|
||||
buttons[0].dispatchEvent(new win.FocusEvent('focusout', { bubbles: true, relatedTarget: null }));
|
||||
expect(isOpen()).toBe(true);
|
||||
rename.click();
|
||||
expect(app.startTabGroupRename).toHaveBeenCalledWith('gx');
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('a pointer outside, Tab, focus leaving, a resize, a second open and a re-render all close it', () => {
|
||||
installFetch();
|
||||
const app = makeApp();
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* @fileoverview A session tab's "+" (`showTabNewButton`, per device, off by
|
||||
* default) and the row menu's "New session here…": the picker of CLIs the
|
||||
* tab's case can start, and the one-off launch beside that tab.
|
||||
*
|
||||
* Driven through the shipping CodemanApp inside JSDOM (app.js, tab-rail-resize.js,
|
||||
* mobile-overview.js and session-ui.js loaded as the page loads them). Pins:
|
||||
* - each entry is a Run menu entry (the CLI's logo, then its name), agents in
|
||||
* catalog order and the shell last, the Run button's mode preselected;
|
||||
* - the tab's case is found by what the session runs on: a Docker session's
|
||||
* container, a remote session's host and path, a local session's folder among
|
||||
* LOCAL cases only (the case records are shaped as GET /api/cases sends them:
|
||||
* display paths for remote and Docker cases, the raw values nested);
|
||||
* - which CLIs are offered follows the Run menu's own rule for that case
|
||||
* (_runModeAvailableForCase): the host's CLIs for a local case, the
|
||||
* container's for a Docker case (live probe, then the stored list, nothing
|
||||
* gated for an owned container), only the shell for an unreadable one;
|
||||
* - a pick runs ONE launch in the tab's case or managed worktree with the tab's
|
||||
* named group, and an ungrouped tab sends no group (the server's case default
|
||||
* group then applies, pinned end to end in case-default-group-routes.test.ts);
|
||||
* - the Run button's mode and the toolbar case are left as they were, also when
|
||||
* the mode is changed while the launch is pending and when the server has
|
||||
* moved lastUsedCase to the tab's case;
|
||||
* - a failed launch reports the server's own error;
|
||||
* - the row menu offers the picker on session rows only while the setting is on;
|
||||
* - the picker closes on a re-render and gives focus back to the tab on Escape;
|
||||
* - the setting stays per device and OFF until switched on, and every new
|
||||
* string has its zh-CN entry.
|
||||
*
|
||||
* Port: none (stubbed fetch).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
|
||||
const CATALOG = [
|
||||
{ id: 'claude', kind: 'agent', label: 'Claude Code', enabled: true },
|
||||
{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true },
|
||||
{ id: 'codex', kind: 'agent', label: 'Codex', enabled: true },
|
||||
{ id: 'gemini', kind: 'agent', label: 'Gemini CLI', enabled: true },
|
||||
{ id: 'pi', kind: 'agent', label: 'Pi', enabled: false },
|
||||
];
|
||||
/** What this host has installed: no gemini. */
|
||||
const HOST = new Set(['claude', 'codex']);
|
||||
/** As GET /api/cases sends them: remote and Docker cases carry a DISPLAY path, the raw values nested. */
|
||||
const CASES = [
|
||||
{ name: 'toolbar-case', path: '/tmp/toolbar-case', location: 'local' },
|
||||
// Same container name and same remote path on ANOTHER host, listed first: only the host tells them apart.
|
||||
{
|
||||
name: 'box-h2',
|
||||
path: 'box:/srv/box',
|
||||
location: 'docker',
|
||||
docker: { hostId: 'h2', container: 'box', path: '/srv/box', containerWorkdir: '/srv/box' },
|
||||
},
|
||||
{
|
||||
name: 'rem2',
|
||||
path: 'dev@host2:/home/dev/work',
|
||||
location: 'remote',
|
||||
remote: { hostId: 'r2', host: 'host2', username: 'dev', path: '/home/dev/work' },
|
||||
},
|
||||
// Inside proj and listed before it: the deepest enclosing case wins, whatever the order.
|
||||
{ name: 'nested', path: '/tmp/proj/nested', location: 'local' },
|
||||
{ name: 'proj', path: '/tmp/proj', location: 'local' },
|
||||
{
|
||||
name: 'box',
|
||||
path: 'box:/srv/box',
|
||||
location: 'docker',
|
||||
docker: {
|
||||
hostId: 'h1',
|
||||
container: 'box',
|
||||
path: '/srv/box',
|
||||
containerWorkdir: '/srv/box',
|
||||
owned: false,
|
||||
availableModes: ['gemini', 'shell'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'own',
|
||||
path: 'own:/srv/own',
|
||||
location: 'docker',
|
||||
docker: { hostId: 'h1', container: 'own', path: '/srv/own', containerWorkdir: '/srv/own' },
|
||||
},
|
||||
// A local case whose folder is the Docker case's workspace: only a LOCAL session belongs to it.
|
||||
{ name: 'clash', path: '/srv/box', location: 'linked-local' },
|
||||
{
|
||||
name: 'rem',
|
||||
path: 'dev@host1:/home/dev/work',
|
||||
location: 'remote',
|
||||
remote: { hostId: 'r1', host: 'host1', username: 'dev', path: '/home/dev/work' },
|
||||
},
|
||||
];
|
||||
const DOCKER_BOX = { hostId: 'h1', containerName: 'box', hostWorkspacePath: '/srv/box', containerWorkdir: '/srv/box' };
|
||||
const DOCKER_OWN = { hostId: 'h1', containerName: 'own', hostWorkspacePath: '/srv/own', containerWorkdir: '/srv/own' };
|
||||
const REMOTE_R1 = { hostId: 'r1', host: 'host1', username: 'dev', remotePath: '/home/dev/work/api' };
|
||||
const SESSIONS: Array<[string, string, Record<string, unknown>?]> = [
|
||||
['a', '/tmp/proj/sub'],
|
||||
['b', '/tmp/proj'],
|
||||
['c', '/srv/box', { docker: DOCKER_BOX }],
|
||||
['d', '/srv/own', { docker: DOCKER_OWN }],
|
||||
['e', '/home/u/scratch'],
|
||||
['f', '/tmp/proj/.claude/worktrees/feat'],
|
||||
['g', '/srv/box'],
|
||||
['r', '/home/dev/work/api', { remote: REMOTE_R1 }],
|
||||
['n', '/tmp/proj/nested/src'],
|
||||
];
|
||||
const LAYOUT = {
|
||||
version: 4,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
groups: [{ id: 'gx', name: 'Core', refs: [{ kind: 'session', id: 'b' }] }],
|
||||
ungrouped: ['a', 'c', 'd', 'e', 'f', 'g', 'r', 'n'].map((id) => ({ kind: 'session', id })),
|
||||
};
|
||||
|
||||
let CodemanApp: { prototype: Record<string, any> };
|
||||
/** The test's app, so afterEach can close its menus: their document listeners would outlive the DOM. */
|
||||
let current: Record<string, any> | null = null;
|
||||
let win: any;
|
||||
let document: Document;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'https://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
if (dom.window.document.readyState !== 'complete') {
|
||||
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
|
||||
}
|
||||
win = dom.window;
|
||||
document = win.document;
|
||||
win.setInterval = () => 0;
|
||||
win.requestAnimationFrame = () => 0;
|
||||
win.CSS = { escape: (value: string) => value };
|
||||
win.eval(
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
[
|
||||
'constants.js',
|
||||
'tab-layout-browser.js',
|
||||
'app.js',
|
||||
'tab-rail-resize.js',
|
||||
'api-client.js',
|
||||
'webview-tabs.js',
|
||||
'mobile-overview.js',
|
||||
'session-ui.js',
|
||||
]
|
||||
.map(read)
|
||||
.join('\n;\n') +
|
||||
'\n;window.__TabNewCodemanApp = CodemanApp;'
|
||||
);
|
||||
CodemanApp = win.__TabNewCodemanApp;
|
||||
});
|
||||
|
||||
function makeApp({ orientation = 'horizontal' } = {}) {
|
||||
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
|
||||
document.documentElement.setAttribute('data-tab-orientation', orientation);
|
||||
document.documentElement.dataset.tabRailSort = 'manual';
|
||||
document.body.innerHTML =
|
||||
'<div id="sessionTabs" role="tablist" aria-label="Session tabs"></div>' +
|
||||
'<select id="quickStartCase"><option value="toolbar-case" selected>toolbar-case</option></select>' +
|
||||
'<input id="tabCount" value="3"><button id="runBtn"></button>';
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map(
|
||||
SESSIONS.map(([id, workingDir, extra]) => [id, { id, name: id, status: 'idle', workingDir, ...extra }])
|
||||
);
|
||||
app.sessionOrder = SESSIONS.map(([id]) => id);
|
||||
app.webviews = new Map();
|
||||
app.webviewOrder = [];
|
||||
app.activeSessionId = 'a';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._tabCollapseStorageFailed = false;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => {};
|
||||
app.updateTabOverflowMode = () => {};
|
||||
app.updateConnectionLines = () => {};
|
||||
app._applyTabEntrances = () => {};
|
||||
app._scrollActiveTabIntoView = () => {};
|
||||
app.applySidebarFilter = () => {};
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app._startSidebarRichClock = () => {};
|
||||
app._stopSidebarRichClock = () => {};
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.openSessionOptions = vi.fn();
|
||||
app.requestCloseSession = vi.fn();
|
||||
app.selectSession = vi.fn(async () => {});
|
||||
app.showToast = vi.fn();
|
||||
app.closeAllPanels = vi.fn();
|
||||
// What the real run() reads beyond the DOM (the same stubs as tab-group-launch.test.ts).
|
||||
app._runMinLockMs = 0;
|
||||
app._runMode = 'codex';
|
||||
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 };
|
||||
app.cases = CASES;
|
||||
app.isCliAvailable = (id: string) => HOST.has(id);
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
app.getEffortSetting = () => undefined;
|
||||
app.isRalphTrackerEnabledByDefault = () => false;
|
||||
app.getTerminalDimensions = () => null;
|
||||
app.loadQuickStartCases = () => {};
|
||||
app._fullRenderSessionTabs();
|
||||
app._applyTabLayout(LAYOUT);
|
||||
current = app;
|
||||
return app;
|
||||
}
|
||||
|
||||
const tab = (id: string) => document.querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
|
||||
const plus = (id: string) => tab(id).querySelector<HTMLElement>('.tab-new')!;
|
||||
const picker = () => document.querySelector<HTMLElement>('[role="menu"][aria-label="New session here"]');
|
||||
/** Each entry as [logo slot classes, visible text, button classes]. */
|
||||
const entries = () =>
|
||||
[...(picker()?.querySelectorAll('button') || [])].map((button) => [
|
||||
button.querySelector('.run-mode-dot')?.className,
|
||||
button.textContent,
|
||||
button.className,
|
||||
]);
|
||||
const openFrom = (app: Record<string, any>, id: string) =>
|
||||
app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus(id) }, id);
|
||||
const pick = (label: string) =>
|
||||
[...picker()!.querySelectorAll<HTMLElement>('button')].find((button) => button.textContent === label)!.click();
|
||||
const rowMenuLabels = () =>
|
||||
[...document.querySelectorAll('.tab-rail-action-menu:not(.tab-layout-group-action-menu) button')].map(
|
||||
(button) => button.textContent
|
||||
);
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 6; i++) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
win.localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.className = '';
|
||||
win.__codemanCliCatalog = CATALOG;
|
||||
win.__codemanUser = { username: 'admin', role: 'admin', multiUser: false };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
current?.closeTabGroupMenu();
|
||||
current?.closeTabRailActionMenu();
|
||||
current = null;
|
||||
});
|
||||
|
||||
describe('the tab "+" picker', () => {
|
||||
it('lists the CLIs the case can start as logo + name, agents in catalog order, the shell last', () => {
|
||||
const app = makeApp();
|
||||
// The markup wires the "+" to this picker for its own tab.
|
||||
expect(plus('a').getAttribute('onclick')).toBe('event.stopPropagation(); app.openTabNewSessionMenu(event, "a")');
|
||||
expect(openFrom(app, 'a')).toBe(true);
|
||||
// gemini is not on this host and pi is disabled in the catalog.
|
||||
expect(entries()).toEqual([
|
||||
['run-mode-dot claude', 'Claude Code', 'run-mode-option'],
|
||||
['run-mode-dot codex', 'Codex', 'run-mode-option selected'],
|
||||
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
|
||||
]);
|
||||
for (const dot of picker()!.querySelectorAll('.run-mode-dot')) expect(dot.getAttribute('aria-hidden')).toBe('true');
|
||||
// The Run button's mode is the one preselected for the keyboard.
|
||||
expect(document.activeElement?.textContent).toBe('Codex');
|
||||
});
|
||||
|
||||
it("a Docker case offers the container's CLIs through the Run menu's rule, never the host's", () => {
|
||||
const app = makeApp();
|
||||
// Adopted, not probed yet: the list stored at adoption (gemini, which the host lacks; no claude, which it has).
|
||||
openFrom(app, 'c');
|
||||
expect(entries()).toEqual([
|
||||
['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'],
|
||||
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
|
||||
]);
|
||||
app.closeTabGroupMenu();
|
||||
// A live probe wins over the stored list.
|
||||
app._dockerCaseModes = { box: ['claude', 'shell'] };
|
||||
openFrom(app, 'c');
|
||||
expect(entries()).toEqual([
|
||||
['run-mode-dot claude', 'Claude Code', 'run-mode-option'],
|
||||
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
|
||||
]);
|
||||
app.closeTabGroupMenu();
|
||||
// An unreadable adopted container offers no agent at all.
|
||||
app._dockerCaseProbeError = { box: 'Could not read container "box".' };
|
||||
openFrom(app, 'c');
|
||||
expect(entries()).toEqual([['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option']]);
|
||||
app.closeTabGroupMenu();
|
||||
// An owned container with nothing probed is not gated: its image ships every CLI.
|
||||
openFrom(app, 'd');
|
||||
expect(entries()).toEqual([
|
||||
['run-mode-dot claude', 'Claude Code', 'run-mode-option'],
|
||||
['run-mode-dot codex', 'Codex', 'run-mode-option selected'],
|
||||
['run-mode-dot gemini', 'Gemini CLI', 'run-mode-option'],
|
||||
['run-mode-dot shell', 'Terminal / Shell', 'run-mode-option'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds the case by what the session runs on, never a same-path case elsewhere', async () => {
|
||||
const app = makeApp();
|
||||
const runs: unknown[] = [];
|
||||
app.run = vi.fn(async (options: unknown) => {
|
||||
runs.push(structuredClone(options));
|
||||
});
|
||||
// A Docker session belongs to its container's case, not to the local case at the same path.
|
||||
openFrom(app, 'c');
|
||||
pick('Terminal / Shell');
|
||||
// A local session at that path belongs to the local case, never the Docker one.
|
||||
openFrom(app, 'g');
|
||||
expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']);
|
||||
pick('Terminal / Shell');
|
||||
// A remote session belongs to the case on its own host, by path prefix.
|
||||
openFrom(app, 'r');
|
||||
pick('Terminal / Shell');
|
||||
// A local session in a case inside another case belongs to the inner one.
|
||||
openFrom(app, 'n');
|
||||
pick('Terminal / Shell');
|
||||
await flush();
|
||||
expect(runs).toStrictEqual([
|
||||
{ count: 1, caseName: 'box', mode: 'shell' },
|
||||
{ count: 1, caseName: 'clash', mode: 'shell' },
|
||||
{ count: 1, caseName: 'rem', mode: 'shell' },
|
||||
{ count: 1, caseName: 'nested', mode: 'shell' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a tab whose folder is no case says so and opens nothing', () => {
|
||||
const app = makeApp();
|
||||
expect(openFrom(app, 'e')).toBe(false);
|
||||
expect(picker()).toBeNull();
|
||||
expect(app.showToast).toHaveBeenCalledWith(
|
||||
"This tab's folder is not a case, so there is nothing to start beside it.",
|
||||
'info'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pick from the "+"', () => {
|
||||
function recordRuns(app: Record<string, any>) {
|
||||
const runs: Array<{ options: unknown; runMode: string }> = [];
|
||||
app.run = vi.fn(async (options: unknown) => {
|
||||
// Cloned into this realm, so toStrictEqual compares keys and values, not JSDOM's prototypes.
|
||||
runs.push({ options: structuredClone(options), runMode: app._runMode });
|
||||
});
|
||||
return runs;
|
||||
}
|
||||
|
||||
it("starts ONE session in the tab's case and named group, then leaves the Run mode and case alone", async () => {
|
||||
const app = makeApp();
|
||||
const runs = recordRuns(app);
|
||||
app.collapsedTabGroupIds.add('gx');
|
||||
openFrom(app, 'b');
|
||||
pick('Claude Code');
|
||||
await flush();
|
||||
// Its group is opened, so the new tab is visible when it arrives.
|
||||
expect(app.collapsedTabGroupIds.has('gx')).toBe(false);
|
||||
// The mode rides the launch; the Run button's own mode is never swapped.
|
||||
expect(runs).toStrictEqual([
|
||||
{ options: { count: 1, caseName: 'proj', tabGroupId: 'gx', mode: 'claude' }, runMode: 'codex' },
|
||||
]);
|
||||
expect(picker()).toBeNull();
|
||||
expect(app._runMode).toBe('codex');
|
||||
expect((document.getElementById('quickStartCase') as HTMLSelectElement).value).toBe('toolbar-case');
|
||||
});
|
||||
|
||||
it('from an ungrouped tab it sends no group at all, so the case default group can place it', async () => {
|
||||
const app = makeApp();
|
||||
const runs = recordRuns(app);
|
||||
openFrom(app, 'a');
|
||||
pick('Terminal / Shell');
|
||||
await flush();
|
||||
expect(runs).toStrictEqual([{ options: { count: 1, caseName: 'proj', mode: 'shell' }, runMode: 'codex' }]);
|
||||
});
|
||||
|
||||
it('from a tab in a managed worktree it starts in that worktree, not the case folder', async () => {
|
||||
const app = makeApp();
|
||||
const runs = recordRuns(app);
|
||||
openFrom(app, 'f');
|
||||
pick('Codex');
|
||||
await flush();
|
||||
expect(runs).toStrictEqual([
|
||||
{
|
||||
options: {
|
||||
count: 1,
|
||||
worktree: { caseName: 'proj', name: 'feat', path: '/tmp/proj/.claude/worktrees/feat' },
|
||||
mode: 'codex',
|
||||
},
|
||||
runMode: 'codex',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses while another launch is in flight, and says so', async () => {
|
||||
const app = makeApp();
|
||||
const runs = recordRuns(app);
|
||||
openFrom(app, 'a');
|
||||
app._runInFlight = true;
|
||||
app.showToast.mockClear();
|
||||
pick('Codex');
|
||||
await flush();
|
||||
expect(runs).toEqual([]);
|
||||
expect(app.showToast).toHaveBeenCalledWith('A launch is already in progress', 'info');
|
||||
expect(app._runMode).toBe('codex');
|
||||
});
|
||||
|
||||
it("a launch the server refuses reports the server's own error", async () => {
|
||||
const app = makeApp();
|
||||
app._runMode = 'claude';
|
||||
const posted: Array<{ url: string; body: unknown }> = [];
|
||||
win.fetch = vi.fn(async (url: string, init?: { body?: string }) => {
|
||||
const body = init?.body ? JSON.parse(init.body) : undefined;
|
||||
posted.push({ url, body });
|
||||
const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value });
|
||||
if (url === '/api/codex/status') return json({ success: true, data: { available: true, runnable: true } });
|
||||
if (url === '/api/quick-start') {
|
||||
return json({ success: false, error: 'Case "proj" has no free session slot', errorCode: 'CONFLICT' });
|
||||
}
|
||||
return json({ success: true, data: {} });
|
||||
});
|
||||
openFrom(app, 'a');
|
||||
app.showToast.mockClear();
|
||||
pick('Codex');
|
||||
await flush();
|
||||
expect(posted.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
|
||||
caseName: 'proj',
|
||||
mode: 'codex',
|
||||
});
|
||||
expect(app.showToast).toHaveBeenLastCalledWith('Case "proj" has no free session slot', 'error');
|
||||
expect(app._runMode).toBe('claude');
|
||||
});
|
||||
|
||||
it('a Run mode chosen while the launch is pending is kept, on screen and on disk', async () => {
|
||||
const app = makeApp();
|
||||
app._applyRunMode = () => {};
|
||||
app._apiPut = vi.fn(async () => ({ success: true }));
|
||||
let finish: () => void = () => {};
|
||||
app.runShell = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
|
||||
openFrom(app, 'a');
|
||||
pick('Terminal / Shell');
|
||||
await flush();
|
||||
expect(app.runShell).toHaveBeenCalledTimes(1);
|
||||
app.setRunMode('claude');
|
||||
finish();
|
||||
await flush();
|
||||
expect(app._runMode).toBe('claude');
|
||||
expect(win.localStorage.getItem('codeman_runMode')).toBe('claude');
|
||||
expect(app._apiPut).toHaveBeenCalledWith('/api/settings', { runMode: 'claude' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a remote Claude launch (quick-start)', 'rem', 'r'],
|
||||
['a local Claude launch', 'proj', 'a'],
|
||||
])("%s leaves the toolbar on its own case, though the server's lastUsedCase moved", async (_label, tabCase, id) => {
|
||||
const app = makeApp();
|
||||
app._runMode = 'claude';
|
||||
app.getAdvisorSetting = () => undefined;
|
||||
app._onSessionCreated = () => {};
|
||||
delete app.loadQuickStartCases; // the shipping reload, not makeApp's no-op
|
||||
// The UI-only steps of the case list reload.
|
||||
for (const name of ['updateDirDisplayForCase', 'updateMobileCaseLabel', 'updateCasePickerInput'])
|
||||
app[name] = () => {};
|
||||
for (const name of ['renderCasePickerList', 'closeCasePicker', 'setupQuickStartCasePicker']) app[name] = () => {};
|
||||
const select = document.getElementById('quickStartCase') as HTMLSelectElement;
|
||||
const record = CASES.find((c) => c.name === tabCase)!;
|
||||
win.fetch = vi.fn(async (url: string) => {
|
||||
const json = (value: unknown) => ({ ok: true, status: 200, json: async () => value });
|
||||
// quick-start saved the launched case as lastUsedCase (session-routes.ts).
|
||||
if (url === '/api/settings') return json({ success: true, data: { lastUsedCase: tabCase } });
|
||||
if (url === '/api/cases') return json({ success: true, data: CASES });
|
||||
if (url === `/api/cases/${tabCase}`) return json({ success: true, data: record });
|
||||
if (url === '/api/quick-start')
|
||||
return json({ success: true, data: { sessionId: 'new1', session: { id: 'new1' } } });
|
||||
if (url === '/api/sessions') return json({ success: true, data: { session: { id: 'new1', name: 'w1' } } });
|
||||
return json({ success: true, data: {} });
|
||||
});
|
||||
openFrom(app, id);
|
||||
pick('Claude Code');
|
||||
await vi.waitFor(() => expect(app.selectSession).toHaveBeenCalledWith('new1'));
|
||||
// The case list is reloaded after the launch (not awaited by it).
|
||||
await vi.waitFor(() => expect(win.fetch.mock.calls.map((call: unknown[]) => call[0])).toContain('/api/cases'));
|
||||
await flush();
|
||||
expect(select.value).toBe('toolbar-case');
|
||||
});
|
||||
});
|
||||
|
||||
describe('"New session here…" in the row menu', () => {
|
||||
const rowMenu = (app: Record<string, any>, trigger: HTMLElement, id: string) =>
|
||||
app.openTabRailActionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: trigger }, id);
|
||||
|
||||
it('is offered on a session row only while the "+" is switched on, and opens the same picker', () => {
|
||||
const app = makeApp({ orientation: 'vertical' });
|
||||
rowMenu(app, tab('a'), 'a');
|
||||
expect(rowMenuLabels()).toEqual([
|
||||
'Session options',
|
||||
'Add to Focus',
|
||||
'Move down',
|
||||
'Move to "Core"',
|
||||
'Move to new group',
|
||||
'Close session',
|
||||
]);
|
||||
app.closeTabRailActionMenu();
|
||||
|
||||
document.documentElement.classList.add('tabs-show-new');
|
||||
rowMenu(app, tab('a'), 'a');
|
||||
expect(rowMenuLabels()).toEqual([
|
||||
'Session options',
|
||||
'New session here…',
|
||||
'Add to Focus',
|
||||
'Move down',
|
||||
'Move to "Core"',
|
||||
'Move to new group',
|
||||
'Close session',
|
||||
]);
|
||||
[...document.querySelectorAll<HTMLElement>('.tab-rail-action-menu button')]
|
||||
.find((button) => button.textContent === 'New session here…')!
|
||||
.click();
|
||||
expect(rowMenuLabels()).toEqual([]);
|
||||
expect(entries().map(([, text]) => text)).toEqual(['Claude Code', 'Codex', 'Terminal / Shell']);
|
||||
// Escape gives the keyboard back to the tab row.
|
||||
document.activeElement!.dispatchEvent(
|
||||
new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
);
|
||||
expect(picker()).toBeNull();
|
||||
expect(document.activeElement).toBe(tab('a'));
|
||||
});
|
||||
|
||||
it("is never offered on a tile's menu, which shares the function", () => {
|
||||
const app = makeApp({ orientation: 'vertical' });
|
||||
document.documentElement.classList.add('tabs-show-new');
|
||||
const tileButton = document.createElement('button');
|
||||
tileButton.className = 'tile-menu';
|
||||
document.body.appendChild(tileButton);
|
||||
rowMenu(app, tileButton, 'a');
|
||||
expect(rowMenuLabels()).toEqual([
|
||||
'Session options',
|
||||
'Add to Focus',
|
||||
'Move down',
|
||||
'Move to "Core"',
|
||||
'Move to new group',
|
||||
'Close session',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('picker lifetime', () => {
|
||||
it('a full re-render closes it and drops its document listeners', () => {
|
||||
const app = makeApp();
|
||||
openFrom(app, 'a');
|
||||
expect(picker()).not.toBeNull();
|
||||
app.sessions.delete('a');
|
||||
app._fullRenderSessionTabs();
|
||||
expect(picker()).toBeNull();
|
||||
expect(app._tabGroupMenuOutside).toBeNull();
|
||||
expect(app._tabGroupMenuKeydown).toBeNull();
|
||||
expect(app._tabGroupMenuResize).toBeNull();
|
||||
});
|
||||
|
||||
it('pressing the same "+" again closes it, like the row menu', () => {
|
||||
const app = makeApp();
|
||||
openFrom(app, 'a');
|
||||
expect(openFrom(app, 'a')).toBe(false);
|
||||
expect(picker()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('showTabNewButton stays per-device and OFF by default', () => {
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const index = read('index.html');
|
||||
|
||||
it('is a display key, stripped from the settings PUT, which would refuse it', () => {
|
||||
const start = settingsUi.indexOf('const displayKeys = new Set([');
|
||||
expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'showTabNewButton'");
|
||||
expect(settingsUi).toContain('showTabNewButton: _tnb,');
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabNewButton: true }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('loads OFF unless switched on, saves from its switch, and drives the class the CSS reads', () => {
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true;"
|
||||
);
|
||||
expect(settingsUi).toContain("showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked,");
|
||||
expect(settingsUi).toContain(
|
||||
"document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true);"
|
||||
);
|
||||
expect(index).toContain('<input type="checkbox" id="appSettingsShowTabNewButton">');
|
||||
});
|
||||
|
||||
it('translates every string it adds', () => {
|
||||
const i18n = read('i18n.js');
|
||||
for (const text of [
|
||||
"'New session here'",
|
||||
"'New session here…'",
|
||||
'"New session in this tab\'s case"',
|
||||
'"This tab\'s folder is not a case, so there is nothing to start beside it."',
|
||||
"'New Session Button on Tabs'",
|
||||
"'A + on the tab that starts a session in the same case and group, in any installed CLI.'",
|
||||
]) {
|
||||
expect(i18n).toContain(`${text}:`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* - a worktree launch goes through the EXISTING create paths with the worktree's folder:
|
||||
* POST /api/sessions with `workingDir` for claude and shell, quick-start with `worktree` for
|
||||
* every other CLI; never a case lookup or scaffold, and tabs named after the worktree;
|
||||
* - `_launchInWorktree()` swaps the run mode for the one launch and puts it back.
|
||||
* - `_launchOnce()` starts the one launch in the chosen mode without touching the Run button's mode.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
@@ -138,15 +138,15 @@ describe('launching in a worktree', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('_launchInWorktree runs ONE session of the chosen mode and restores the Run button mode', async () => {
|
||||
it("_launchOnce runs ONE session of the chosen mode in the worktree and leaves the Run button's mode", async () => {
|
||||
const { app } = load(async () => json({}));
|
||||
app._runMode = 'claude';
|
||||
const seen: Array<{ mode: string; options: unknown }> = [];
|
||||
app.run = vi.fn(async (options: unknown) => {
|
||||
seen.push({ mode: app._runMode, options });
|
||||
});
|
||||
await app._launchInWorktree(WT, 'codex');
|
||||
expect(seen).toEqual([{ mode: 'codex', options: { count: 1, worktree: WT } }]);
|
||||
await app._launchOnce('codex', { worktree: WT });
|
||||
expect(seen).toEqual([{ mode: 'claude', options: { count: 1, worktree: WT, mode: 'codex' } }]);
|
||||
expect(app._runMode).toBe('claude');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user