mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
feat(tiles): sessions Run from this tab join the open grid; + offers a new session in the tile's case
Every Run path makes each session it created visible through _ensureCreatedSessionVisible and then selects the first one, a human selection that used to leave the grid for the single view. That helper now hands the new session to _joinTileGridFromRun: with the grid open it joins the next free slot, so Run's selection focuses its tile. No Attach overlay flashes on it while Run starts its pane. Sessions created elsewhere (agents, other devices, cron) arrive only by session:created and never join; a grid already holding what the window fits does not take it, and a hint says the new session opens on its own. A tile's + adds "New session in this case": the normal Run (current run mode) for the case the tile's session belongs to, with the toolbar's case put back afterwards; the session it creates joins the grid like any Run from this tab. Disabled for a session outside every case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -573,6 +573,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
if (session?.id) this._onSessionCreated(session);
|
||||
// A session this tab's Run created joins an open tile grid (tile-grid.js),
|
||||
// so Run's selectSession() below focuses its tile instead of leaving the
|
||||
// grid. Only here: sessions created elsewhere arrive by session:created.
|
||||
this._joinTileGridFromRun?.(sessionId);
|
||||
// session:created normally uses the debounced renderer. The direct POST path
|
||||
// needs the tab in the DOM before selectSession() marks it active.
|
||||
this._renderSessionTabsImmediate?.();
|
||||
|
||||
@@ -465,7 +465,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* adds it to the grid and focuses it (a human selection). Disabled once the
|
||||
* grid holds what the window can fit.
|
||||
*/
|
||||
openTileAddMenu(event) {
|
||||
openTileAddMenu(event, fromSessionId = null) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
const grid = this._tileGrid;
|
||||
@@ -504,6 +504,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
menu.appendChild(item);
|
||||
}
|
||||
// A new session in the case this tile's session belongs to: the normal Run
|
||||
// for that case, which then joins the grid like any Run from this tab.
|
||||
const fromSession = fromSessionId ? this.sessions.get(fromSessionId) : null;
|
||||
const theCase = fromSession ? this._mobileOverviewCaseFor?.(fromSession.workingDir, this.cases || []) : null;
|
||||
const create = document.createElement('button');
|
||||
create.type = 'button';
|
||||
create.className = 'tile-add-new';
|
||||
create.setAttribute('role', 'menuitem');
|
||||
create.textContent = 'New session in this case';
|
||||
create.disabled = full || !theCase;
|
||||
if (!theCase) create.title = 'This session is not in a case';
|
||||
else if (full) create.title = `The grid already holds what this window fits (${capacity})`;
|
||||
create.addEventListener('click', () => {
|
||||
this.closeTileAddMenu();
|
||||
if (theCase) void this.runInCaseForTiles(theCase.name);
|
||||
});
|
||||
menu.appendChild(create);
|
||||
document.body.appendChild(menu);
|
||||
if (trigger?.getBoundingClientRect) {
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
@@ -524,6 +541,47 @@ Object.assign(CodemanApp.prototype, {
|
||||
menu.querySelector?.('button:not([disabled])')?.focus?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* Runs the normal Run (current run mode) in `caseName`, then puts the
|
||||
* toolbar's case back as it was. The session it creates joins the grid
|
||||
* through _joinTileGridFromRun.
|
||||
*/
|
||||
async runInCaseForTiles(caseName) {
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const previous = select?.value;
|
||||
const swap = !!select && !!caseName && previous !== caseName;
|
||||
if (swap) this.selectQuickStartCase?.(caseName, { save: false });
|
||||
try {
|
||||
await this.run?.();
|
||||
} finally {
|
||||
if (swap && previous) this.selectQuickStartCase?.(previous, { save: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A session THIS tab's Run just created (session-ui.js
|
||||
* _ensureCreatedSessionVisible, reached only from the Run paths): with the
|
||||
* grid open it joins the next free slot, and Run's own selectSession then
|
||||
* focuses it through the tile branch. Sessions created elsewhere (agents,
|
||||
* other devices, cron) arrive only by session:created and never join. A grid
|
||||
* already holding what the window fits does not take it; Run's selection
|
||||
* then shows it in the single view (decision 1), and a hint says why.
|
||||
*/
|
||||
_joinTileGridFromRun(sessionId) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false;
|
||||
const T = window.CodemanTileGrid;
|
||||
const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX));
|
||||
if (grid.ids.length >= capacity) {
|
||||
this.showToast?.(`The grid holds what this window fits (${capacity}): the new session opens on its own`, 'info');
|
||||
return false;
|
||||
}
|
||||
// Run starts the session right after creating it: no Attach overlay
|
||||
// meanwhile for a pane that is about to exist.
|
||||
(this._tileAttachPending ||= new Map()).set(sessionId, Date.now());
|
||||
return this.addTile(sessionId);
|
||||
},
|
||||
|
||||
/** Idempotent, like closeTilePicker. */
|
||||
closeTileAddMenu() {
|
||||
const m = this._tileAddMenu;
|
||||
@@ -1087,7 +1145,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
actions.append(
|
||||
button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
|
||||
zoomBtn,
|
||||
button('tile-add', 'Add a session to the grid', '+', (e) => this.openTileAddMenu(e)),
|
||||
button('tile-add', 'Add a session to the grid', '+', (e) => this.openTileAddMenu(e, sessionId)),
|
||||
// Removes the tile ONLY: the session keeps running. Killing it stays
|
||||
// behind the menu's Close session and its confirm.
|
||||
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () =>
|
||||
|
||||
@@ -219,7 +219,7 @@ const context = vm.createContext({
|
||||
removeEventListener: documentRemoveEventListener,
|
||||
documentElement: { dataset: {} },
|
||||
createElement: () => new FakeEl(),
|
||||
getElementById: (id: string) => (id === 'tileGrid' ? section : null),
|
||||
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
|
||||
body,
|
||||
querySelector: (sel: string) =>
|
||||
bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @fileoverview New sessions started from THIS tab's Run join the open grid.
|
||||
*
|
||||
* Every Run path calls `_ensureCreatedSessionVisible` for each session it
|
||||
* created, then `selectSession(firstId)` (a human selection). With the grid open,
|
||||
* `_joinTileGridFromRun` (called from there) adds each new session to the next
|
||||
* free slot, so that selection focuses its tile instead of leaving the grid.
|
||||
* Sessions created elsewhere (agents, other devices, cron) arrive only by
|
||||
* `session:created` and never join. A grid already holding what the window
|
||||
* fits does not take it: Run's selection then shows it alone, with a hint.
|
||||
*
|
||||
* A tile's + also offers "New session in this case": the normal Run for the
|
||||
* case the tile's session belongs to, the toolbar's case put back afterwards.
|
||||
*
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts); the Run
|
||||
* hook in session-ui.js is pinned at the source. Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FakeEl,
|
||||
body,
|
||||
bySelector,
|
||||
makeGridApp,
|
||||
resetGridHarness,
|
||||
section,
|
||||
type GridApp,
|
||||
} from './mocks/tile-grid-vm.js';
|
||||
|
||||
const IDS = ['s-a', 's-b'];
|
||||
const addSession = (app: GridApp, id: string, workingDir = '/w') =>
|
||||
app.sessions.set(id, { id, name: id, mode: 'claude', pid: null, workingDir });
|
||||
|
||||
beforeEach(() => {
|
||||
resetGridHarness();
|
||||
});
|
||||
|
||||
describe('Run from this tab', () => {
|
||||
it('the Run hook is where every Run path makes its new session visible', () => {
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
const helper = src.slice(src.indexOf('async _ensureCreatedSessionVisible('), src.indexOf('async run() {'));
|
||||
expect(helper).toContain('this._joinTileGridFromRun?.(sessionId);');
|
||||
});
|
||||
|
||||
it('with the grid open, the new session joins the next free slot; Run then focuses its tile', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS);
|
||||
addSession(app, 's-new');
|
||||
expect(app._joinTileGridFromRun('s-new')).toBe(true);
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-new']);
|
||||
// What Run does next: a human selection of the session it created.
|
||||
app.selectSession('s-new');
|
||||
expect(app._tilesOwnTerminal()).toBe(true);
|
||||
expect(app.activeSessionId).toBe('s-new');
|
||||
});
|
||||
|
||||
it('no Attach overlay flashes on the new tile while Run starts its pane', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS);
|
||||
addSession(app, 's-new');
|
||||
app._joinTileGridFromRun('s-new');
|
||||
const tile = section.children.find((el) => el.dataset.sessionId === 's-new') as FakeEl;
|
||||
const overlay = tile.children[1].children.find((c) => c.className === 'tile-attach');
|
||||
expect(!overlay || overlay.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('with the grid closed, nothing joins', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
addSession(app, 's-new');
|
||||
expect(app._joinTileGridFromRun('s-new')).toBe(false);
|
||||
expect(app._tileGrid?.open ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it('a grid that already holds what the window fits does not take it, and says so', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS);
|
||||
addSession(app, 's-new');
|
||||
section.getBoundingClientRect = () => ({ width: 1000, height: 400, top: 0, left: 0, right: 1000, bottom: 400 });
|
||||
expect(app._joinTileGridFromRun('s-new')).toBe(false);
|
||||
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||
expect(app._tileGrid.ids).toEqual(IDS);
|
||||
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('opens on its own'), 'info');
|
||||
});
|
||||
|
||||
it('a session created ELSEWHERE (session:created) never joins', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS);
|
||||
app.renderSessionTabs = vi.fn();
|
||||
app.saveSessionOrder = vi.fn();
|
||||
app.updateCost = vi.fn();
|
||||
app._onSessionCreated({ id: 's-agent', name: 's-agent', mode: 'claude', pid: 1, workingDir: '/w' });
|
||||
expect(app.sessions.has('s-agent')).toBe(true);
|
||||
expect(app._tileGrid.ids).toEqual(IDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('+ / New session in this case', () => {
|
||||
function openMenu(app: GridApp, fromId: string) {
|
||||
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, fromId);
|
||||
const menu = body.children.find((c) => c.className.includes('tile-add-menu'))!;
|
||||
return menu.children.find((c) => c.className === 'tile-add-new')!;
|
||||
}
|
||||
|
||||
it('runs the normal Run in the tile session case, then puts the toolbar case back', async () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.cases = [{ name: 'proj', path: '/w' }];
|
||||
app._mobileOverviewCaseFor = (dir: string, cases: Array<{ name: string; path: string }>) =>
|
||||
cases.find((c) => dir.startsWith(c.path)) ?? null;
|
||||
const select = new FakeEl();
|
||||
select.value = 'other-case';
|
||||
bySelector.set('#quickStartCase', select);
|
||||
const calls: string[] = [];
|
||||
app.selectQuickStartCase = vi.fn((name: string) => {
|
||||
calls.push(`case:${name}`);
|
||||
select.value = name;
|
||||
});
|
||||
app.run = vi.fn(async () => calls.push(`run in ${select.value}`));
|
||||
app.openTileGrid(IDS);
|
||||
const item = openMenu(app, 's-a');
|
||||
expect(item.disabled).toBe(false);
|
||||
item.dispatch('click');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(3));
|
||||
expect(calls).toEqual(['case:proj', 'run in proj', 'case:other-case']);
|
||||
expect(app.selectQuickStartCase).toHaveBeenCalledWith('proj', { save: false });
|
||||
});
|
||||
|
||||
it('is disabled for a session that is not in a case', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.cases = [];
|
||||
app._mobileOverviewCaseFor = () => null;
|
||||
app.openTileGrid(IDS);
|
||||
expect(openMenu(app, 's-a').disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -211,7 +211,7 @@ describe("a tile's + menu", () => {
|
||||
app.openTileGrid(['s-a', 's-b']);
|
||||
app.markIdleAlertSeen.mockClear();
|
||||
addButtonOf('s-a').dispatch('click', { stopPropagation: vi.fn(), currentTarget: null });
|
||||
const items = addMenu()!.children;
|
||||
const items = addMenu()!.children.filter((i) => i.className !== 'tile-add-new');
|
||||
expect(items.map((i) => i.textContent)).toEqual(['s-other', 's-c']);
|
||||
items[1].dispatch('click');
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
|
||||
|
||||
Reference in New Issue
Block a user