Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions frontend/src/components/Shell/PaneStrip.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useLayoutEffect, useRef } from 'react'
import AppWindow from 'lucide-react/dist/esm/icons/app-window.mjs'
import GripVertical from 'lucide-react/dist/esm/icons/grip-vertical.mjs'
import MessageSquare from 'lucide-react/dist/esm/icons/message-square.mjs'
import Settings from 'lucide-react/dist/esm/icons/settings.mjs'
import X from 'lucide-react/dist/esm/icons/x.mjs'
Expand Down Expand Up @@ -109,6 +110,15 @@ export function PaneTab({
onMouseDown={(e) => { if (e.button === 1) e.preventDefault() }}
onContextMenu={onContextMenu}
>
{dragKey && (
<span
className="shell__tab-drag-handle"
data-touch-drag-handle={dragKey}
aria-hidden="true"
>
<GripVertical size={12} />
</span>
)}
<TabIcon size={13} aria-hidden="true" />
<span ref={titleRef} className="shell__tab-text">
<span className="shell__tab-text-inner">{label}</span>
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/components/Shell/Shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,26 @@
font-size: 12.5px;
-webkit-tap-highlight-color: transparent;
}
/* A draggable tab owns its touch gesture after pointerdown. This matches mature
dock/tab UIs: the surrounding strip still pans natively, while the concrete drag
source cannot be pointercancelled when the gesture turns horizontal. */
/* The tab body remains the strip's native horizontal pan surface. A vertical pull
can still lift the tab, while the dedicated grip below owns both axes for an
intentional horizontal reorder. This keeps overflowed tabs reachable on touch. */
.shell__tab-open[data-drag-key] {
touch-action: pan-x pinch-zoom;
}
.shell__tab-drag-handle {
align-self: stretch;
display: grid;
place-items: center;
flex: none;
width: 24px;
margin: -5px 0 -5px -5px;
color: var(--muted);
cursor: grab;
touch-action: none;
}
.shell__tab-drag-handle:active {
cursor: grabbing;
}
.shell__tab-open:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--accent);
Expand Down
21 changes: 8 additions & 13 deletions frontend/src/components/Shell/Shell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import {
import PaneChatView from './PaneChatView.jsx'
import ErrorBoundary from '../ErrorBoundary/ErrorBoundary.jsx'
import {
deriveContentVisibility, deriveExitPlan, deriveEnterPlan, exitSignature, MODE_MOTION,
deriveContentVisibility, deriveExitPlan, deriveEnterPlan, transitionSignature, MODE_MOTION,
EMPTY_SINGLE_SURFACE_KEY,
} from './workspaceView.js'
import NewChatLanding from './NewChatLanding.jsx'
Expand Down Expand Up @@ -403,20 +403,15 @@ export default function Shell() {
const immersiveHolderRef = useRef(immersiveAppId)
immersiveHolderRef.current = immersiveAppId

// INV 10 / H2: a topology, geometry, OR DESTINATION change DURING an exit beat makes
// the latched plan no longer describe what single mode will paint — a participant
// pane closes, its active tab changes, the slot moves, the box resizes, OR a Settings
// takeover / immersive request suspends or lands over the slot mid-beat. Cancel
// rather than retarget a live transform: recompute the exit signature from the same
// projection authority AND the live overlay classification, and compare to the
// latched one. Because the signature folds the destination through the SAME
// classifier the plan used, every input the plan derives from also drifts this key —
// so the live overlay state (settingsOpenRaw / immersiveAppId) is both read here and
// in the deps, letting a mid-beat destination flip fire this. Only caller of cancelBeat.
// INV 10 / H2: a topology, geometry, OR DESTINATION change DURING either animated
// beat makes its latched FLIP/edge transforms stale. Cancel rather than retarget a
// live transform: recompute the shared transition signature from the same projection
// authority and overlay classification both plan builders use. The live overlay state
// is in the deps, so a mid-beat Settings/immersive destination change also snaps.
useEffect(() => {
const t = modeState.transition
if (!t || t.phase !== 'exiting' || !t.presentation) return
const live = exitSignature({
if (!t?.presentation) return
const live = transitionSignature({
workspace, projection, contentRect,
settingsDestination: settingsOpenRaw,
immersiveHolderId: immersiveAppId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ test('passedSlop arms only past the slop radius', () => {
assert.equal(passedSlop(3, 3), false) // hypot 4.24 < 5
})

test('touchMoveIntent lets a tab drag on either axis while preserving drawer scrolling', () => {
test('touchMoveIntent preserves tab-body scrolling and gives the grip either-axis drag', () => {
assert.equal(touchMoveIntent(8, 0, 'tab'), 'pending')
assert.equal(touchMoveIntent(0, 8.1, 'tab'), 'drag', 'vertical pull drags a horizontal tab strip')
assert.equal(touchMoveIntent(8.1, 0, 'tab'), 'drag', 'horizontal pull reorders a tab')
assert.equal(touchMoveIntent(8.1, 0, 'tab'), 'scroll', 'horizontal pull scrolls over a tab body')
assert.equal(touchMoveIntent(8.1, 0, 'tab-handle'), 'drag', 'the grip reorders horizontally')
assert.equal(touchMoveIntent(0, 8.1, 'tab-handle'), 'drag', 'the grip can move across panes')
assert.equal(touchMoveIntent(8.1, 0, 'drawer'), 'drag', 'horizontal pull drags a vertical drawer row')
assert.equal(touchMoveIntent(0, 8.1, 'drawer'), 'scroll', 'vertical pull scrolls the drawer')
assert.equal(touchMoveIntent(10, 10, 'drawer'), 'scroll', 'ambiguous diagonals favor native scroll')
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/components/Shell/__tests__/modeReviewFixes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,14 @@ test('finding 5: completion captures the originating epoch, not the current tran
assert.equal(modeReducer(s, { type: 'complete', id: e1 }).transition.id, e3, 'stale epoch rejected')
})

// -- Finding 6 / INV 10 / H2: cancelBeat is wired to a plan-signature drift ------
test('finding 6: a topology/geometry/destination change during an exit beat cancels it (INV 10 / H2)', () => {
// -- Finding 6 / INV 10 / H2: cancelBeat is wired to either plan's signature drift --
test('finding 6: topology/geometry/destination drift during either beat cancels it (INV 10 / H2)', () => {
assert.match(shell, /mode\.cancelBeat\(\)/)
// v2: the cancel watcher recomputes the exit signature from the same projection
// authority AND the live overlay classification, comparing it to the latched
// snapshotSignature — any drift snaps. H2: the destination inputs (settingsOpenRaw /
// immersiveAppId) are folded in and in the deps, so a mid-beat destination flip fires it.
assert.match(shell, /const live = exitSignature\(\{/)
// The watcher recomputes one signature for both enter and exit plans. There is no
// phase-only gate that would leave geometry-dependent entry transforms stale.
assert.match(shell, /if \(!t\?\.presentation\) return/)
assert.doesNotMatch(shell, /t\.phase !== 'exiting'/)
assert.match(shell, /const live = transitionSignature\(\{/)
assert.match(shell, /settingsDestination: settingsOpenRaw/)
assert.match(shell, /immersiveHolderId: immersiveAppId/)
assert.match(shell, /\}, \[workspace, projection, contentRect, settingsOpenRaw, immersiveAppId, modeState, mode\]\)/)
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/components/Shell/__tests__/workspaceUi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,16 @@ test('the logo keeps the stable "Toggle navigation" name; gesture rides aria-des
assert.match(shellBrand, /builderModeActive \? 'Builder mode' : 'Single screen'/)
})

test('mobile tabs own either-axis dragging while empty strip space retains native pan-x', () => {
test('mobile tab bodies pan while a dedicated grip owns either-axis dragging', () => {
assert.match(shellCss, /\.shell__tabstrip\s*\{[\s\S]*?touch-action:\s*pan-x pinch-zoom/)
assert.match(shellCss, /\.shell__tab-open\[data-drag-key\]\s*\{[\s\S]*?touch-action:\s*none/)
assert.match(shellCss, /\.shell__tab-open\[data-drag-key\]\s*\{[\s\S]*?touch-action:\s*pan-x pinch-zoom/)
assert.match(shellCss, /\.shell__tab-drag-handle\s*\{[\s\S]*?touch-action:\s*none/)
assert.match(paneStrip, /data-touch-drag-handle=\{dragKey\}/)
assert.equal((paneStrip.match(/data-drag-key=\{dragKey\}/g) || []).length, 1,
'the nested grip must not duplicate the generic drag-source selector')
assert.match(drawerCss, /\.drawer__row \.drawer__item\[data-drag-key\]\s*\{[\s\S]*?touch-action:\s*pan-y pinch-zoom/)
assert.match(dragBinding, /touchMoveIntent\(dx, dy, sourceKind\)/)
assert.match(dragBinding, /downEvent\.target\?\.closest\?\.\('\[data-touch-drag-handle\]'\)/)
assert.match(dragBinding, /touchMoveIntent\(dx, dy, touchIntentKind\)/)
assert.doesNotMatch(dragBinding, /addEventListener\('touchmove'/)
})

Expand Down
39 changes: 25 additions & 14 deletions frontend/src/components/Shell/__tests__/workspaceView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import * as paneModel from '../paneModel.js'
import * as tabModel from '../tabModel.js'
import {
deriveContentVisibility, deriveExitPlan, deriveEnterPlan, exitSignature, MODE_MOTION,
deriveContentVisibility, deriveExitPlan, deriveEnterPlan, transitionSignature, MODE_MOTION,
EMPTY_SINGLE_SURFACE_KEY,
} from '../workspaceView.js'

Expand Down Expand Up @@ -672,7 +672,8 @@ test('deriveEnterPlan: the shared surface settles while siblings assemble from t

test('deriveEnterPlan: a tree-absent single surface stays beneath the assembling panes', () => {
const ws = { ...twoPaneChatAndApp(), singleScreen: { kind: 'chat', id: '99' } }
const plan = deriveEnterPlan({ workspace: ws, projection: project(ws), contentRect: CONTENT })
const input = { workspace: ws, projection: project(ws), contentRect: CONTENT }
const plan = deriveEnterPlan(input)
assert.equal(plan.target, 'chat:99')
assert.equal(plan.underlayKey, 'chat:99')
assert.deepEqual(plan.completionNames, ['shell-mode-deal-in'])
Expand All @@ -681,6 +682,12 @@ test('deriveEnterPlan: a tree-absent single surface stays beneath the assembling
const right = plan.participants.find(p => p.key === 'app:42')
assert.ok(left.offset.x < 0)
assert.ok(right.offset.x > 0)
assert.equal(plan.snapshotSignature, transitionSignature(input), 'entry latches its input snapshot')
assert.notEqual(
plan.snapshotSignature,
transitionSignature({ ...input, contentRect: { ...CONTENT, w: CONTENT.w - 120 } }),
'a mid-entry content resize invalidates the latched edge offsets',
)
})

test('edge motions accept Shell\'s origin-free live content rect', () => {
Expand All @@ -699,33 +706,37 @@ test('edge motions accept Shell\'s origin-free live content rect', () => {
}
})

test('exitSignature is stable for the same tree and drifts on a topology/geometry change (INV 10)', () => {
test('transitionSignature is stable and drifts on topology/content-bound changes (INV 10)', () => {
const ws = { ...twoPaneChatAndApp(), singleScreen: { kind: 'chat', id: '99' } }
const base = exitSignature({ workspace: ws, projection: project(ws), contentRect: CONTENT })
assert.equal(base, exitSignature({ workspace: ws, projection: project(ws), contentRect: CONTENT }))
const base = transitionSignature({ workspace: ws, projection: project(ws), contentRect: CONTENT })
assert.equal(base, transitionSignature({ workspace: ws, projection: project(ws), contentRect: CONTENT }))
// A content-box resize drifts the signature → the beat cancels.
const resized = exitSignature({ workspace: ws, projection: project(ws), contentRect: { x: 0, y: 0, w: 800, h: 600 } })
const resized = transitionSignature({ workspace: ws, projection: project(ws), contentRect: { x: 0, y: 0, w: 800, h: 600 } })
assert.notEqual(base, resized)
const movedBounds = transitionSignature({
workspace: ws, projection: project(ws), contentRect: { ...CONTENT, x: 12 },
})
assert.notEqual(base, movedBounds, 'edge offsets include the content origin when supplied')
// A divider ratio changes edge offsets without changing the content bounds, so
// per-pane rects are part of the invalidation signature too.
const resizedPane = paneModel.setRatio(ws, ws.layout.id, 0.62)
assert.notEqual(base, exitSignature({ workspace: resizedPane, projection: project(resizedPane), contentRect: CONTENT }))
assert.notEqual(base, transitionSignature({ workspace: resizedPane, projection: project(resizedPane), contentRect: CONTENT }))
// A different slot target drifts it too.
const retargeted = exitSignature({ workspace: { ...ws, singleScreen: { kind: 'chat', id: '5' } }, projection: project(ws), contentRect: CONTENT })
const retargeted = transitionSignature({ workspace: { ...ws, singleScreen: { kind: 'chat', id: '5' } }, projection: project(ws), contentRect: CONTENT })
assert.notEqual(base, retargeted)
})

test('exitSignature folds the destination so a mid-beat destination change cancels (H2)', () => {
test('transitionSignature folds the destination so a mid-beat destination change cancels (H2)', () => {
// The audit case: a live exit plan built toward the chat slot must cancel the moment
// a Settings takeover suspends over the slot mid-beat — the two signatures differ.
const ws = { ...twoPaneChatAndApp(), singleScreen: { kind: 'chat', id: '5' } }
const proj = project(ws)
const toChat = exitSignature({ workspace: ws, projection: proj, contentRect: CONTENT })
const toSettings = exitSignature({ workspace: ws, projection: proj, contentRect: CONTENT, settingsDestination: true })
const toChat = transitionSignature({ workspace: ws, projection: proj, contentRect: CONTENT })
const toSettings = transitionSignature({ workspace: ws, projection: proj, contentRect: CONTENT, settingsDestination: true })
assert.notEqual(toChat, toSettings, 'chat-target vs settings:settings destinations must differ')
// And the PLAN's stored snapshot equals a live recompute at the SAME destination, so
// the watcher never false-cancels while the destination holds (structural coupling:
// deriveExitPlan feeds its own input object to exitSignature).
// deriveExitPlan feeds its own input object to transitionSignature).
const settingsPlan = deriveExitPlan({ workspace: ws, projection: proj, contentRect: CONTENT, settingsDestination: true })
assert.equal(settingsPlan.snapshotSignature, toSettings)
const chatPlan = deriveExitPlan({ workspace: ws, projection: proj, contentRect: CONTENT })
Expand All @@ -734,8 +745,8 @@ test('exitSignature folds the destination so a mid-beat destination change cance
// signature differs from the ordinary reveal, so a mid-beat immersive request cancels.
const app42 = { ...twoPaneChatAndApp(), singleScreen: { kind: 'app', id: '42' } }
const projApp = project(app42)
const reveal = exitSignature({ workspace: app42, projection: projApp, contentRect: CONTENT })
const immersive = exitSignature({ workspace: app42, projection: projApp, contentRect: CONTENT, immersiveHolderId: 42 })
const reveal = transitionSignature({ workspace: app42, projection: projApp, contentRect: CONTENT })
const immersive = transitionSignature({ workspace: app42, projection: projApp, contentRect: CONTENT, immersiveHolderId: 42 })
assert.notEqual(reveal, immersive, 'an immersive-instant destination must drift the signature')
})

Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/Shell/dragController.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,17 @@ export function passedSlop(dx, dy, slop = POINTER_SLOP) {
}

// Drawer rows live in a vertical list, so vertical movement stays native scrolling
// and a horizontal pull becomes a drag. A concrete pane tab is different: its CSS
// owns the pointer stream (`touch-action:none`) while the surrounding strip retains
// native pan-x. Once a tab moves past the threshold, either axis therefore means the
// user is repositioning it — including the horizontal reorder that was impossible
// when pan-x caused the browser to cancel the pointer.
// and a horizontal pull becomes a drag. Tab bodies live in a horizontal strip: a
// horizontal move stays native scrolling, while a vertical pull lifts the tab. The
// dedicated tab handle owns its pointer stream in CSS and can therefore reorder on
// either axis without taking horizontal scrolling away from the tab body.
export function touchMoveIntent(dx, dy, sourceKind, limit = PRE_HOLD_MOVE_PX) {
if (hypot(dx, dy) <= limit) return 'pending'
const x = Math.abs(dx)
const y = Math.abs(dy)
if (sourceKind === 'drawer') return x > y ? 'drag' : 'scroll'
return 'drag'
if (sourceKind === 'tab-handle') return 'drag'
return y > x ? 'drag' : 'scroll'
}

// After a lift, a release still within this radius opened no drag — it is the
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/components/Shell/useWorkspaceDrag.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ export default function useWorkspaceDrag({
// ── One drag session ──────────────────────────────────────────────────────
function startSession(downEvent, srcEl, sourceKind, key, paneId) {
const isTouch = downEvent.pointerType !== 'mouse'
const touchIntentKind = sourceKind === 'tab'
&& downEvent.target?.closest?.('[data-touch-drag-handle]')
? 'tab-handle'
: sourceKind
const start = { x: downEvent.clientX, y: downEvent.clientY }
const pointerId = downEvent.pointerId
let armed = false
Expand Down Expand Up @@ -294,9 +298,10 @@ export default function useWorkspaceDrag({
}
}

// A deliberate tab move (either axis) or cross-axis drawer-row move arms
// immediately. A stationary hold is the alternate path to the tab/row menu;
// it deliberately does not unfold the workspace just because time passed.
// A vertical tab-body move, either-axis grip move, or cross-axis drawer-row
// move arms immediately. A stationary hold is the alternate path to the
// tab/row menu; it deliberately does not unfold the workspace just because
// time passed.
if (isTouch) {
holdTimer = setTimeout(() => {
if (cancelled || cleaned) return
Expand Down Expand Up @@ -378,7 +383,7 @@ export default function useWorkspaceDrag({
const dy = ev.clientY - start.y
if (!armed) {
if (isTouch) {
const intent = touchMoveIntent(dx, dy, sourceKind)
const intent = touchMoveIntent(dx, dy, touchIntentKind)
if (intent === 'scroll') { cancelled = true; cleanup(); return }
if (intent === 'drag') {
clearTimeout(holdTimer)
Expand Down
Loading
Loading