mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix(tiles): no tile onto an empty slot, header focus on click, arrow chords skip text fields
The owner's answers on moving tiles: - "dont move the tile": an empty slot no longer takes a tile. A slot is always the last cell, so a move there shifted every tile after it. Each drop target now says what it accepts (_acceptTabDrops' `accepts`): a tile takes any session but its own, an empty slot only a session not tiled yet. A refused drag is still held (dropEffect none, no highlight), and dropSessionOnSlot refuses a tiled session too, its tab included. A session not tiled yet still joins on a slot. - A cancelled drag changes nothing, focus included (best practice): the header focuses its tile on click, never on press, so a drag that ends with Escape or outside leaves focus and the idle alert alone. The body keeps press-to-focus, so focus still moves before a press reaches xterm. The rename input stops its own clicks. - A tiled tab dropped on the zoomed tile stays refused (confirmed). - The Alt+Shift+Arrow focus chords skip a text field too (best practice), as the move chords already did: shifted arrows select there. Toggle and zoom are not text-editing keys and are unchanged. Docs: the wiki, the spec's as-built bullet (with the owner's answers), the invariants and CLAUDE.md say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -807,13 +807,13 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE its await (like `wasActive`; the delete broadcast may already have removed the tile) and focuses it with `auto`; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead.
|
||||
|
||||
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI) and never in a text field other than xterm's own textarea (`isTextFieldTarget`), where Ctrl+Shift+Arrows select by word.
|
||||
⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select.
|
||||
|
||||
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does).
|
||||
|
||||
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
|
||||
|
||||
⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile.
|
||||
⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own, an empty slot only a session not tiled yet, so a tile is never moved to a slot; a refused drag is still held (`dropEffect: 'none'`, no highlight). Every move (that drag, a tiled tab's drop on a tile, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile.
|
||||
|
||||
⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot <cliId>` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it.
|
||||
|
||||
|
||||
+20
-15
@@ -68,21 +68,26 @@ or settled a question the spec left open. The invariants as built are in
|
||||
batches, so lifecycle and hook events are unaffected, and leaving the grid
|
||||
re-subscribes the shown session.
|
||||
- **Tiles move** (owner request: "give me the option to move the tiles around"; not a
|
||||
numbered decision). A tile's header, its free area (not the buttons, not the rename input),
|
||||
drags it: onto another tile the two trade places, onto an empty slot it moves there (the
|
||||
last cell: empty slots are always last, so the tiles after it close up). It is a native drag
|
||||
through the tab drop targets (capture phase, stopped before xterm), carrying a type of its
|
||||
own and never text, and it is not `draggedTabId`, so neither a text field nor the tab strip
|
||||
takes it; Escape or a drop anywhere else cancels with nothing changed. `Ctrl+Shift+Arrows`
|
||||
(Move Tile Left/Right/Up/Down, registry, rebindable) swap the focused tile with the
|
||||
neighbour the focus chords pick, and focus stays on it. Every move, a tiled tab's drop
|
||||
included, goes through `_reorderTiles`: no remount, reconnect or reload; divider sizes
|
||||
belong to the cells, so only a tile whose cell size changed fits (one PTY resize, #464).
|
||||
Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so their
|
||||
keys never reach the CLI) and with a single tile, and the chords skip a text field, where
|
||||
Ctrl+Shift+Arrows select by word. Default keys: every other two-modifier arrow chord is
|
||||
taken (Ctrl+Alt switches workspaces, Ctrl+Alt+Shift moves a window to another workspace
|
||||
in GNOME, Alt is back/forward, Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by
|
||||
numbered decision). A tile's header, its free area (not the buttons, not the rename
|
||||
input), drags it onto another tile and the two trade places. An empty slot refuses a tile,
|
||||
its header drag and its tab alike (owner's answer, "dont move the tile": a slot is always
|
||||
the last cell, so a move there shifted every tile after it); a session not tiled yet still
|
||||
joins there. It is a native drag through the tab drop targets (capture phase, stopped
|
||||
before xterm), carrying a type of its own and never text, and it is not `draggedTabId`, so
|
||||
neither a text field nor the tab strip takes it; Escape or a drop anywhere else cancels
|
||||
with nothing changed, focus included: the header focuses its tile on click, not on press
|
||||
(owner's answer: best practice; the body keeps press-to-focus, so focus moves before a
|
||||
press reaches xterm). `Ctrl+Shift+Arrows` (Move Tile Left/Right/Up/Down, registry,
|
||||
rebindable) swap the focused tile with the neighbour the focus chords pick, and focus
|
||||
stays on it. Every move, a tiled tab's drop included, goes through `_reorderTiles`: no
|
||||
remount, reconnect or reload; divider sizes belong to the cells, so only a tile whose cell
|
||||
size changed fits (one PTY resize, #464). Moving is off while a tile is zoomed (the chords
|
||||
still apply there, as a no-op, so their keys never reach the CLI; a tiled tab dropped on
|
||||
the zoomed tile is refused too, as the owner confirmed) and with a single tile. Both arrow
|
||||
chord families, focus and move, skip a text field, where shifted arrows select (owner's
|
||||
answer: best practice). Default keys: every other two-modifier arrow chord is taken
|
||||
(Ctrl+Alt switches workspaces, Ctrl+Alt+Shift moves a window to another workspace in
|
||||
GNOME, Alt is back/forward, Alt+Shift focuses tiles); Ctrl+Shift+Arrows is unclaimed by
|
||||
the browsers, GNOME, KDE, macOS and Claude Code, and costs only a terminal editor's word
|
||||
selection inside a tile while the grid is open.
|
||||
- **A tile that joins before its pane exists resends its size when the pid appears**
|
||||
|
||||
@@ -46,7 +46,7 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa
|
||||
| `Ctrl+Shift+G` | Open or close the tile grid (needs the Tiles setting on). |
|
||||
| `Alt+Shift+Arrows` | Focus the tile to the left, right, above or below. |
|
||||
| `Ctrl+Shift+Arrows` | Move the focused tile: it trades places with that neighbour. |
|
||||
| Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. |
|
||||
| Drag a tile's header | Move the tile: dropped on another tile, the two swap. |
|
||||
| `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. |
|
||||
| `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. |
|
||||
| Right-click the Tiles button | Choose which sessions to show as tiles. |
|
||||
|
||||
@@ -65,8 +65,10 @@ whose agent exited inside its pane says so instead; close that session from `⋯
|
||||
## Moving tiles
|
||||
|
||||
Drag a tile by its header (anywhere but its buttons) onto another tile and the two trade
|
||||
places. Drop it on an empty slot and it moves there, to the end of the grid. Press `Escape`
|
||||
or let go anywhere else and nothing moves. The dropped tile takes the focus.
|
||||
places; the dropped tile takes the focus. Empty slots are for adding sessions (drop a tab
|
||||
there), so a tile cannot be dropped on one. Press `Escape` or let go anywhere else and nothing
|
||||
changes, not even which tile has the focus: a header focuses its tile when you click it, not
|
||||
when you press it.
|
||||
|
||||
With the keyboard, `Ctrl+Shift+Arrows` moves the focused tile left, right, up or down: it
|
||||
trades places with the tile next to it (the one `Alt+Shift+Arrows` would focus) and keeps the
|
||||
@@ -90,10 +92,10 @@ saved with the grid.
|
||||
|
||||
All of them can be rebound in App Settings → Shortcuts, where **Remove Focused Tile** can
|
||||
also get a key. Outside the grid, `Alt+Shift+Arrows`, `Ctrl+Shift+Arrows` and
|
||||
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Ctrl+Shift+Arrows` in a text
|
||||
field (renaming a tile, the file editor) still selects by word; inside a tile it moves the tile,
|
||||
so a terminal editor there (nano, micro, emacs) does not get it. With the Tiles setting off,
|
||||
`Ctrl+Shift+G` does nothing.
|
||||
`Alt+Shift+Enter` go to the terminal as usual. While it is open, `Alt+Shift+Arrows` and
|
||||
`Ctrl+Shift+Arrows` in a text field (renaming a tile, the file editor) still select text there;
|
||||
inside a tile they focus and move tiles, so a terminal editor there (nano, micro, emacs) does
|
||||
not get them. With the Tiles setting off, `Ctrl+Shift+G` does nothing.
|
||||
|
||||
## Leaving the grid
|
||||
|
||||
|
||||
Reference in New Issue
Block a user