Skip to content

Commit bebdb47

Browse files
QuickMythrilclaude
andauthored
feat(home-v2): draggable tabs and browser tab gestures (#351)
Adds reorder-tab / reorder-internal ProductActions (clamped final index, same-state no-op, groups reorder independently so internal pages stay ahead of app tabs) and a pointer-capture drag in TabStrip mirroring v1: 5px start threshold, live reorder at sibling midpoints, click suppression after a completed drag, close-button exempt from capture. Gestures: middle-click (auxclick) closes app or internal tabs, double-click on the empty strip opens the configured new tab, and ArrowLeft/Right/Home/End move focus between tab buttons without activating (Enter/Space still activates). Order persists via the existing product serialization. Drag-out-to-detach is deferred: v2 windows share one persisted shell state and the existing windows:openTabInNewWindow payload is v1-shaped. Parity review P-4 (projects/qortium-home-2.1/parity-review-2026-08-24). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1554c5f commit bebdb47

7 files changed

Lines changed: 350 additions & 5 deletions

File tree

‎QORTIUM-HOME-CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ both networks through explicit compatibility and security boundaries.
3434

3535
## Change Entries
3636

37+
### 2026-08-24 - feat(home-v2): draggable tabs and browser tab gestures
38+
39+
Tabs in Home 2 can now be dragged to reorder, the way they could in
40+
Home 1: hold and drag a tab sideways and the strip reorders live under
41+
the pointer. Home page tabs reorder among themselves and app tabs among
42+
themselves, so pages stay grouped ahead of apps. The strip also gained
43+
the familiar browser gestures: middle-click closes a tab,
44+
double-clicking the empty area of the strip opens a new tab, and with
45+
focus in the strip the Left/Right arrow keys (plus Home and End) move
46+
between tabs without switching to them. The new order is remembered
47+
across restarts. Dragging a tab out to detach it into its own window is
48+
not included yet — it needs a design for how separate windows share
49+
saved state.
50+
3751
### 2026-08-24 - feat(home-v2): allow multiple internal pages open as tabs
3852

3953
Home 2 previously kept a single shared slot for its own pages, so

‎src/home-v2-live/HomeV2LiveApp.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4882,6 +4882,12 @@ export function HomeV2LiveApp() {
48824882
dispatchProduct({ type: 'close-tab', tabId })
48834883
}}
48844884
onCloseInternal={(page) => dispatchProduct({ type: 'close-internal', page })}
4885+
onReorderTab={(tabId, toIndex) =>
4886+
dispatchProduct({ type: 'reorder-tab', tabId, toIndex })
4887+
}
4888+
onReorderInternal={(page, toIndex) =>
4889+
dispatchProduct({ type: 'reorder-internal', page, toIndex })
4890+
}
48854891
onAppNavigationChanged={handleAppNavigationChanged}
48864892
onAppNavigationControllerChange={handleAppNavigationControllerChange}
48874893
onAppTitleChanged={handleAppTitleChanged}

‎src/v2/home-v2-foundation.test.tsx‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,64 @@ function testProductModelKeepsSourceQualifiedTabs(): void {
440440
'settings',
441441
])
442442

443+
// Tab drag reorder (P-4): tabs reorder within their own group only.
444+
const [firstTab, secondTab] = dashboard.tabs
445+
const swappedTabs = reduceProductState(dashboard, {
446+
type: 'reorder-tab',
447+
tabId: firstTab.id,
448+
toIndex: 1,
449+
})
450+
assert.deepEqual(
451+
swappedTabs.tabs.map((tab) => tab.id),
452+
[secondTab.id, firstTab.id],
453+
)
454+
assert.equal(swappedTabs.destination, dashboard.destination)
455+
assert.equal(
456+
reduceProductState(dashboard, {
457+
type: 'reorder-tab',
458+
tabId: firstTab.id,
459+
toIndex: 0,
460+
}),
461+
dashboard,
462+
)
463+
const clampedReorder = reduceProductState(dashboard, {
464+
type: 'reorder-tab',
465+
tabId: firstTab.id,
466+
toIndex: 99,
467+
})
468+
assert.deepEqual(
469+
clampedReorder.tabs.map((tab) => tab.id),
470+
[secondTab.id, firstTab.id],
471+
)
472+
assert.throws(
473+
() =>
474+
reduceProductState(dashboard, {
475+
type: 'reorder-tab',
476+
tabId: fixtureIds.tab,
477+
toIndex: 0,
478+
}),
479+
(error) => {
480+
assert.ok(error instanceof ProductModelError)
481+
assert.equal(error.code, 'TAB_NOT_FOUND')
482+
return true
483+
},
484+
)
485+
const swappedPages = reduceProductState(settingsOpen, {
486+
type: 'reorder-internal',
487+
page: 'settings',
488+
toIndex: 0,
489+
})
490+
assert.deepEqual(swappedPages.internalPages, ['settings', 'dashboard'])
491+
assert.equal(swappedPages.destination, 'settings')
492+
assert.equal(
493+
reduceProductState(settingsOpen, {
494+
type: 'reorder-internal',
495+
page: 'settings',
496+
toIndex: 5,
497+
}),
498+
settingsOpen,
499+
)
500+
443501
assert.throws(
444502
() =>
445503
reduceProductState(empty, {

‎src/v2/product-model.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ export type ProductAction =
5858
readonly destination: Exclude<ShellDestination, 'tab'>
5959
}
6060
| { readonly type: 'close-internal'; readonly page: InternalPageId }
61+
| {
62+
readonly type: 'reorder-tab'
63+
readonly tabId: TabId
64+
readonly toIndex: number
65+
}
66+
| {
67+
readonly type: 'reorder-internal'
68+
readonly page: InternalPageId
69+
readonly toIndex: number
70+
}
6171
| { readonly type: 'restore'; readonly state: ProductState }
6272

6373
export class ProductModelError extends Error {
@@ -429,6 +439,59 @@ function setTabTitle(
429439
})
430440
}
431441

442+
// toIndex is the tab's desired final index within its own group (clamped).
443+
// Reordering never changes which surface is active.
444+
function reorderAppTab(
445+
state: ProductState,
446+
tabId: TabId,
447+
toIndex: number,
448+
): ProductState {
449+
const fromIndex = state.tabs.findIndex((tab) => tab.id === tabId)
450+
if (fromIndex < 0) {
451+
throw new ProductModelError('TAB_NOT_FOUND', `Tab ${tabId} was not found.`)
452+
}
453+
const clamped = Math.max(
454+
0,
455+
Math.min(state.tabs.length - 1, Math.trunc(toIndex)),
456+
)
457+
if (clamped === fromIndex) return state
458+
const tabs = [...state.tabs]
459+
const [moved] = tabs.splice(fromIndex, 1)
460+
tabs.splice(clamped, 0, moved)
461+
return freezeProductState({
462+
...state,
463+
tabs,
464+
revision: state.revision + 1,
465+
})
466+
}
467+
468+
function reorderInternalPage(
469+
state: ProductState,
470+
page: InternalPageId,
471+
toIndex: number,
472+
): ProductState {
473+
const fromIndex = state.internalPages.indexOf(page)
474+
if (fromIndex < 0) {
475+
throw new ProductModelError(
476+
'TAB_NOT_FOUND',
477+
`Internal page ${page} is not open.`,
478+
)
479+
}
480+
const clamped = Math.max(
481+
0,
482+
Math.min(state.internalPages.length - 1, Math.trunc(toIndex)),
483+
)
484+
if (clamped === fromIndex) return state
485+
const internalPages = [...state.internalPages]
486+
const [moved] = internalPages.splice(fromIndex, 1)
487+
internalPages.splice(clamped, 0, moved)
488+
return freezeProductState({
489+
...state,
490+
internalPages,
491+
revision: state.revision + 1,
492+
})
493+
}
494+
432495
export function reduceProductState(
433496
state: ProductState,
434497
action: ProductAction,
@@ -454,6 +517,10 @@ export function reduceProductState(
454517
})
455518
case 'close-internal':
456519
return closeInternalPage(state, action.page)
520+
case 'reorder-tab':
521+
return reorderAppTab(state, action.tabId, action.toIndex)
522+
case 'reorder-internal':
523+
return reorderInternalPage(state, action.page, action.toIndex)
457524
case 'restore':
458525
return freezeProductState(action.state)
459526
}

‎src/v2/shell/BrowserChrome.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export interface BrowserChromeProps {
3232
readonly onActivateTab?: (tabId: ProductState['tabs'][number]['id']) => void
3333
readonly onCloseTab?: (tabId: ProductState['tabs'][number]['id']) => void
3434
readonly onCloseInternal?: (page: InternalPageId) => void
35+
readonly onReorderTab?: (
36+
tabId: ProductState['tabs'][number]['id'],
37+
toIndex: number,
38+
) => void
39+
readonly onReorderInternal?: (page: InternalPageId, toIndex: number) => void
3540
readonly onNavigate?: (
3641
destination: Exclude<ShellDestination, 'tab'>,
3742
) => void
@@ -113,6 +118,8 @@ export function BrowserChrome({
113118
onActivateTab,
114119
onCloseTab,
115120
onCloseInternal,
121+
onReorderTab,
122+
onReorderInternal,
116123
onNavigate,
117124
onOpenAddress,
118125
onOpenAsWidget,
@@ -269,6 +276,8 @@ export function BrowserChrome({
269276
onActivateTab={onActivateTab}
270277
onCloseTab={onCloseTab}
271278
onCloseInternal={onCloseInternal}
279+
onReorderTab={onReorderTab}
280+
onReorderInternal={onReorderInternal}
272281
onNavigate={onNavigate}
273282
onNewTab={openNewTab}
274283
newTabDisabled={navigationDisabled}

‎src/v2/shell/HomeV2Prototype.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ export interface HomeV2PrototypeProps {
164164
readonly onActivateTab?: (tabId: ProductState['tabs'][number]['id']) => void
165165
readonly onCloseTab?: (tabId: ProductState['tabs'][number]['id']) => void
166166
readonly onCloseInternal?: (page: InternalPageId) => void
167+
readonly onReorderTab?: (
168+
tabId: ProductState['tabs'][number]['id'],
169+
toIndex: number,
170+
) => void
171+
readonly onReorderInternal?: (page: InternalPageId, toIndex: number) => void
167172
readonly onNavigate?: (
168173
destination: Exclude<ShellDestination, 'tab'>,
169174
) => void
@@ -1001,6 +1006,8 @@ export function HomeV2Prototype(props: HomeV2PrototypeProps) {
10011006
onActivateTab,
10021007
onCloseTab,
10031008
onCloseInternal,
1009+
onReorderTab,
1010+
onReorderInternal,
10041011
onNavigate,
10051012
onResolvePermission,
10061013
} = props
@@ -1121,6 +1128,8 @@ export function HomeV2Prototype(props: HomeV2PrototypeProps) {
11211128
onActivateTab={guardedActivateTab}
11221129
onCloseTab={onCloseTab}
11231130
onCloseInternal={guardedCloseInternal}
1131+
onReorderTab={onReorderTab}
1132+
onReorderInternal={onReorderInternal}
11241133
onNavigate={guardedNavigate}
11251134
onOpenAddress={props.onOpenAddress}
11261135
onOpenAsWidget={props.onOpenAsWidget}

0 commit comments

Comments
 (0)