mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(desktop): scope multi-pane model UI and stabilize tile chrome (#67855)
* fix(desktop): scope multi-pane model UI and stabilize tile chrome
Composer model controls were still keyed off the primary session globals, so every tile showed the same model and a busy primary blocked switches in idle panes. Bind the pill/menu/select path to SessionView, force lone session-tile headers (incl. after tab cycle), and persist strip order so add/remove/switch stops scrambling adjacent panes.
* fix(desktop): scope preset effort/fast writes per surface, simplify tile order sync
A tile's model pick still pushed effort/fast onto the primary composer globals via applyModelPreset — scope it to the surface (primary → globals, tile → its session slice). Tile order persistence drops the before-stamping walk for a plain sort by tree encounter order; restore replays the array sequentially so array order is strip order.
* test(desktop): cover tile strip-order + selection-home; fix stale docs
Extract syncTileStripOrder's sort into a pure `orderTilesByTree` and the
selection listener's guard into `selectionHomesToWorkspace` (same shape as
the PR's lone-header extraction), then unit-test both — the two store
behaviors that shipped without coverage. Correct the `anchor`/`before` docs
(now persisted, not in-memory) and note that a tile's effort/fast edit still
writes the shared per-model preset even though the session write is scoped.
* fix(desktop): drop forbidden import() type annotations in model tests
`importOriginal<typeof import('…')>()` trips consistent-type-imports (error)
and reddens the desktop lint job. Switch to the repo's accepted top-level
`import type * as X` + `typeof X` form, matching skills/index.test.tsx.
This commit is contained in:
parent
e702a45b5d
commit
3aeded6e32
19 changed files with 508 additions and 114 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatBarState } from '@/app/chat/composer/types'
|
||||
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
|
||||
import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session'
|
||||
|
||||
import { ModelPill } from './model-pill'
|
||||
|
|
@ -28,7 +30,7 @@ describe('ModelPill pinned-override badge', () => {
|
|||
setCurrentModelSource('manual')
|
||||
$activeSessionId.set(null)
|
||||
|
||||
render(<ModelPill disabled={false} model={modelState()} />)
|
||||
render(<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />)
|
||||
|
||||
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
|
||||
})
|
||||
|
|
@ -59,13 +61,55 @@ describe('ModelPill pinned-override badge', () => {
|
|||
$activeSessionId.set(null)
|
||||
|
||||
// Fallback (no live menu) path.
|
||||
const { unmount } = render(<ModelPill disabled={false} model={modelState()} />)
|
||||
const { unmount } = render(
|
||||
<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />
|
||||
)
|
||||
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
|
||||
unmount()
|
||||
|
||||
// Live-menu (dropdown) path.
|
||||
render(<ModelPill disabled={false} model={modelState({ modelMenuContent: <div /> })} />)
|
||||
render(
|
||||
<ModelPill
|
||||
disabled={false}
|
||||
model={modelState({ model: 'deepseek/deepseek-v4-flash', modelMenuContent: <div /> })}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
|
||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ModelPill per-surface model label', () => {
|
||||
it('shows the chat-bar model even when the primary global differs', () => {
|
||||
setCurrentModel('primary/model')
|
||||
$activeSessionId.set('primary-runtime')
|
||||
|
||||
const tileView: SessionView = {
|
||||
kind: 'tile',
|
||||
$awaitingResponse: atom(false),
|
||||
$busy: atom(false),
|
||||
$cwd: atom(''),
|
||||
$fast: atom(false),
|
||||
$lastVisibleIsUser: atom(false),
|
||||
$messages: atom([]),
|
||||
$messagesEmpty: atom(true),
|
||||
$model: atom('tile/claude-sonnet'),
|
||||
$provider: atom('anthropic'),
|
||||
$reasoningEffort: atom('high'),
|
||||
$runtimeId: atom('tile-runtime'),
|
||||
$storedId: atom('stored-tile')
|
||||
}
|
||||
|
||||
render(
|
||||
<SessionViewProvider value={tileView}>
|
||||
<ModelPill
|
||||
disabled={false}
|
||||
model={modelState({ model: 'tile/claude-sonnet', provider: 'anthropic', modelMenuContent: <div /> })}
|
||||
/>
|
||||
</SessionViewProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Sonnet · High')).toBeTruthy()
|
||||
expect(screen.queryByText(/primary/i)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
|
@ -10,15 +11,7 @@ import { useI18n } from '@/i18n'
|
|||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentModelSource,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
import { $currentModelSource, setModelPickerOpen } from '@/store/session'
|
||||
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
|
|
@ -31,6 +24,9 @@ const PILL = cn(
|
|||
* Composer model selector — the relocated status-bar pill. Reuses the live
|
||||
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
|
||||
* full picker when the gateway is closed and no live menu exists.
|
||||
*
|
||||
* Display follows THIS surface's SessionView (primary or tile) — never the
|
||||
* primary-only globals — so side-by-side panes each show their own model.
|
||||
*/
|
||||
export function ModelPill({
|
||||
compact = false,
|
||||
|
|
@ -42,12 +38,17 @@ export function ModelPill({
|
|||
model: ChatBarState['model']
|
||||
}) {
|
||||
const copy = useI18n().t.shell.statusbar
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const fastMode = useStore($currentFastMode)
|
||||
const reasoningEffort = useStore($currentReasoningEffort)
|
||||
const view = useSessionView()
|
||||
// Prefer the chat-bar snapshot (already view-scoped by ChatView); fall back
|
||||
// to the live SessionView atoms so a mid-flight session.info still paints.
|
||||
const viewModel = useStore(view.$model)
|
||||
const viewProvider = useStore(view.$provider)
|
||||
const currentModel = model.model || viewModel
|
||||
const currentProvider = model.provider || viewProvider
|
||||
const fastMode = useStore(view.$fast)
|
||||
const reasoningEffort = useStore(view.$reasoningEffort)
|
||||
const modelSource = useStore($currentModelSource)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// The composer pick is sticky: a manual selection is pinned and every NEW
|
||||
|
|
@ -55,7 +56,9 @@ export function ModelPill({
|
|||
// cost users real money on a forgotten paid-model pick (#62055). Surface the
|
||||
// pin whenever a draft (no live session) is running on a manual override. A
|
||||
// live session's footer reflects that session's model, so no badge there.
|
||||
const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim())
|
||||
// Tiles always have a runtime — pin badge is primary-draft only.
|
||||
const pinnedOverride =
|
||||
view.kind === 'primary' && !runtimeId && modelSource === 'manual' && Boolean(currentModel.trim())
|
||||
|
||||
// The model resolves a beat after the gateway/session comes up. Rather than
|
||||
// flash a literal "No model", show a quiet loader (inherits the pill text
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@
|
|||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useModelControls } from '@/app/session/hooks/use-model-controls'
|
||||
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
|
|
@ -84,11 +87,13 @@ function buildTileView(storedSessionId: string): SessionView {
|
|||
$awaitingResponse: computed($state, state => Boolean(state?.awaitingResponse)),
|
||||
$busy: computed($state, state => Boolean(state?.busy)),
|
||||
$cwd: computed($state, state => state?.cwd ?? ''),
|
||||
$fast: computed($state, state => Boolean(state?.fast)),
|
||||
$lastVisibleIsUser: computed($messages, lastVisibleMessageIsUser),
|
||||
$messages,
|
||||
$messagesEmpty: computed($messages, messages => messages.length === 0),
|
||||
$model: computed($state, state => state?.model ?? ''),
|
||||
$provider: computed($state, state => state?.provider ?? ''),
|
||||
$reasoningEffort: computed($state, state => state?.reasoningEffort ?? ''),
|
||||
$runtimeId,
|
||||
// Constant for the tile's lifetime — a plain atom, not a computed.
|
||||
$storedId: atom(storedSessionId)
|
||||
|
|
@ -105,7 +110,10 @@ function TileChat({
|
|||
view: SessionView
|
||||
}) {
|
||||
const { gatewayRef, requestGateway } = useGatewayRequest()
|
||||
const queryClient = useQueryClient()
|
||||
const { selectModel } = useModelControls({ queryClient, requestGateway })
|
||||
const cwd = useStore(view.$cwd)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
|
||||
// One attachment set + focus key per tile, stable for the tile's lifetime.
|
||||
const attachments = useRef(createComposerAttachmentScope()).current
|
||||
|
|
@ -132,11 +140,26 @@ function TileChat({
|
|||
scope: { add: attachments.add, remove: attachments.remove, target: scope.target }
|
||||
})
|
||||
|
||||
// Per-tile model menu — rendered under this tile's SessionView so the pill
|
||||
// + switch target THIS runtime, not the primary (which may be mid-turn).
|
||||
const modelMenuContent = useMemo(
|
||||
() =>
|
||||
gatewayOpen ? (
|
||||
<ModelMenuPanel
|
||||
gateway={gatewayRef.current || undefined}
|
||||
onSelectModel={selectModel}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
) : null,
|
||||
[gatewayOpen, gatewayRef, requestGateway, selectModel]
|
||||
)
|
||||
|
||||
return (
|
||||
<SessionViewProvider value={view}>
|
||||
<ComposerScopeProvider value={scope}>
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import {
|
|||
$awaitingResponse,
|
||||
$busy,
|
||||
$currentCwd,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
|
|
@ -38,6 +40,8 @@ export interface SessionView {
|
|||
$cwd: ReadableAtom<string>
|
||||
$model: ReadableAtom<string>
|
||||
$provider: ReadableAtom<string>
|
||||
$fast: ReadableAtom<boolean>
|
||||
$reasoningEffort: ReadableAtom<string>
|
||||
}
|
||||
|
||||
export const PRIMARY_SESSION_VIEW: SessionView = {
|
||||
|
|
@ -45,11 +49,13 @@ export const PRIMARY_SESSION_VIEW: SessionView = {
|
|||
$awaitingResponse,
|
||||
$busy,
|
||||
$cwd: $currentCwd,
|
||||
$fast: $currentFastMode,
|
||||
$lastVisibleIsUser: $lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$model: $currentModel,
|
||||
$provider: $currentProvider,
|
||||
$reasoningEffort: $currentReasoningEffort,
|
||||
$runtimeId: $activeSessionId,
|
||||
$storedId: $selectedStoredSessionId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -918,7 +918,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
setCurrentModelSource('default')
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
updateModelOptionsCache($activeSessionId.get(), provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import type { ModelSelection } from '@/app/shell/model-menu-panel'
|
||||
import { ModelPickerDialog } from '@/components/model-picker'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import {
|
||||
|
|
@ -11,19 +11,28 @@ import {
|
|||
$modelPickerOpen,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
import { $focusedRuntimeId, $focusedSessionState } from '@/store/session-states'
|
||||
|
||||
interface ModelPickerOverlayProps {
|
||||
gateway?: HermesGateway
|
||||
onSelect: React.ComponentProps<typeof ModelPickerDialog>['onSelect']
|
||||
onSelect: (selection: ModelSelection) => void
|
||||
}
|
||||
|
||||
export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const primarySessionId = useStore($activeSessionId)
|
||||
const primaryModel = useStore($currentModel)
|
||||
const primaryProvider = useStore($currentProvider)
|
||||
const focusedRuntimeId = useStore($focusedRuntimeId)
|
||||
const focusedState = useStore($focusedSessionState)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const open = useStore($modelPickerOpen)
|
||||
|
||||
// Prefer the focused tile's runtime when the overlay opens from a tile that
|
||||
// lacked a live menu (gateway closed → fallback path).
|
||||
const sessionId = focusedRuntimeId ?? primarySessionId
|
||||
const currentModel = focusedRuntimeId && focusedState ? focusedState.model : primaryModel
|
||||
const currentProvider = focusedRuntimeId && focusedState ? focusedState.provider : primaryProvider
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -34,9 +43,9 @@ export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProp
|
|||
currentProvider={currentProvider}
|
||||
gw={gateway}
|
||||
onOpenChange={setModelPickerOpen}
|
||||
onSelect={onSelect}
|
||||
onSelect={selection => onSelect({ ...selection, sessionId })}
|
||||
open={open}
|
||||
sessionId={activeSessionId}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
setCurrentModelSource,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import type * as SessionStates from '@/store/session-states'
|
||||
|
||||
import { useModelControls } from './use-model-controls'
|
||||
|
||||
|
|
@ -30,9 +31,19 @@ function deferred<T>() {
|
|||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/session-states', async importOriginal => {
|
||||
const actual = await importOriginal<typeof SessionStates>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
sessionTileDelegate: () => null
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
|
|
@ -333,4 +344,31 @@ describe('useModelControls', () => {
|
|||
expect($currentProvider.get()).toBe('openai-codex')
|
||||
expect(getCurrentModelSource()).toBe('default')
|
||||
})
|
||||
|
||||
it('targets an explicit tile sessionId without clobbering the primary model', async () => {
|
||||
$activeSessionId.set('primary-runtime')
|
||||
setCurrentModel('primary/model')
|
||||
setCurrentProvider('openai')
|
||||
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'tile-model' }) as never)
|
||||
let controls!: Controls
|
||||
|
||||
render(<Harness onReady={value => (controls = value)} requestGateway={requestGateway} />)
|
||||
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'tile-model',
|
||||
provider: 'anthropic',
|
||||
sessionId: 'tile-runtime'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('config.set', {
|
||||
session_id: 'tile-runtime',
|
||||
key: 'model',
|
||||
value: 'tile-model --provider anthropic --session'
|
||||
})
|
||||
// Primary footer untouched — the busy primary must not absorb a tile pick.
|
||||
expect($currentModel.get()).toBe('primary/model')
|
||||
expect($currentProvider.get()).toBe('openai')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
import type { ModelSelection } from '@/app/shell/model-menu-panel'
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { manualPickRemoved } from '@/lib/model-options'
|
||||
|
|
@ -16,13 +17,9 @@ import {
|
|||
setCurrentModelSource,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
model: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface ModelControlsOptions {
|
||||
queryClient: QueryClient
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
|
|
@ -39,10 +36,10 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
|
|||
// callbacks once and never re-evaluate — a captured prop would be stale
|
||||
// forever. The store read is always current.
|
||||
const updateModelOptionsCache = useCallback(
|
||||
(provider: string, model: string, includeGlobal: boolean) => {
|
||||
(sessionId: null | string, provider: string, model: string, includeGlobal: boolean) => {
|
||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', $activeSessionId.get() || 'global'], patch)
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', sessionId || 'global'], patch)
|
||||
|
||||
if (includeGlobal) {
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)
|
||||
|
|
@ -129,21 +126,39 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
|
|||
// it's scoped to that session via config.set. It NEVER writes the profile
|
||||
// default — that lives in Settings → Model — so picking a model here can't
|
||||
// silently mutate global config.
|
||||
//
|
||||
// `selection.sessionId` targets a specific surface (tile). When omitted, the
|
||||
// primary `$activeSessionId` is used (overlay / legacy callers). A tile
|
||||
// switch must not touch the primary globals — and must not be blocked by a
|
||||
// busy primary turn.
|
||||
const selectModel = useCallback(
|
||||
async (selection: ModelSelection): Promise<boolean> => {
|
||||
// Snapshot for rollback: the switch is applied optimistically, so a
|
||||
// failure must restore the prior model/provider (store + query cache)
|
||||
// rather than leave the UI showing a model the backend never selected.
|
||||
const prevModel = $currentModel.get()
|
||||
const prevProvider = $currentProvider.get()
|
||||
const primaryRuntimeId = $activeSessionId.get()
|
||||
const liveSessionId = 'sessionId' in selection ? (selection.sessionId ?? null) : primaryRuntimeId
|
||||
const touchesPrimary = !liveSessionId || liveSessionId === primaryRuntimeId
|
||||
|
||||
const prevModel = touchesPrimary ? $currentModel.get() : ($sessionStates.get()[liveSessionId!]?.model ?? '')
|
||||
|
||||
const prevProvider = touchesPrimary
|
||||
? $currentProvider.get()
|
||||
: ($sessionStates.get()[liveSessionId!]?.provider ?? '')
|
||||
|
||||
const prevSource = getCurrentModelSource()
|
||||
|
||||
const liveSessionId = $activeSessionId.get()
|
||||
if (touchesPrimary) {
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
markComposerSelectionManual()
|
||||
} else if (liveSessionId) {
|
||||
// Optimistic tile paint — session.info will confirm; rollback on error.
|
||||
sessionTileDelegate()?.updateSession(liveSessionId, state => ({
|
||||
...state,
|
||||
model: selection.model,
|
||||
provider: selection.provider
|
||||
}))
|
||||
}
|
||||
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
markComposerSelectionManual()
|
||||
updateModelOptionsCache(selection.provider, selection.model, !liveSessionId)
|
||||
updateModelOptionsCache(liveSessionId, selection.provider, selection.model, touchesPrimary && !liveSessionId)
|
||||
|
||||
// No live session yet: the pick is pure UI state. session.create reads
|
||||
// $currentModel/$currentProvider and applies it as that session's override.
|
||||
|
|
@ -162,10 +177,19 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
|
|||
|
||||
return true
|
||||
} catch (err) {
|
||||
setCurrentModel(prevModel)
|
||||
setCurrentProvider(prevProvider)
|
||||
setCurrentModelSource(prevSource)
|
||||
updateModelOptionsCache(prevProvider, prevModel, !liveSessionId)
|
||||
if (touchesPrimary) {
|
||||
setCurrentModel(prevModel)
|
||||
setCurrentProvider(prevProvider)
|
||||
setCurrentModelSource(prevSource)
|
||||
} else if (liveSessionId) {
|
||||
sessionTileDelegate()?.updateSession(liveSessionId, state => ({
|
||||
...state,
|
||||
model: prevModel,
|
||||
provider: prevProvider
|
||||
}))
|
||||
}
|
||||
|
||||
updateModelOptionsCache(liveSessionId, prevProvider, prevModel, touchesPrimary && !liveSessionId)
|
||||
notifyError(err, copy.modelSwitchFailed)
|
||||
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type * as HermesApi from '@/hermes'
|
||||
import { $modelPresets, getModelPreset } from '@/store/model-presets'
|
||||
import {
|
||||
$activeSessionId,
|
||||
|
|
@ -20,6 +21,12 @@ import {
|
|||
|
||||
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => {
|
||||
const actual = await importOriginal<typeof HermesApi>()
|
||||
|
||||
return { ...actual, setApiRequestProfile: vi.fn() }
|
||||
})
|
||||
|
||||
// Radix calls these on open; jsdom doesn't implement them.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
|
|
@ -15,12 +16,8 @@ import { useI18n } from '@/i18n'
|
|||
import { normalize } from '@/lib/text'
|
||||
import { setModelPreset } from '@/store/model-presets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
markComposerSelectionManual,
|
||||
setCurrentFastMode,
|
||||
setCurrentReasoningEffort
|
||||
} from '@/store/session'
|
||||
import { markComposerSelectionManual, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
|
||||
import { sessionTileDelegate } from '@/store/session-states'
|
||||
|
||||
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
||||
// by the Thinking toggle, not the radio.
|
||||
|
|
@ -110,14 +107,17 @@ export function ModelEditSubmenu({
|
|||
}: ModelEditSubmenuProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.modelOptions
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const view = useSessionView()
|
||||
const activeSessionId = useStore(view.$runtimeId)
|
||||
const touchesPrimary = view.kind === 'primary'
|
||||
|
||||
const effortValue = normalizeEffort(effort)
|
||||
const thinkingOn = isThinkingEnabled(effort)
|
||||
|
||||
// Editing always records the model's global preset; the active model also gets
|
||||
// it pushed onto the live session. Non-active edits stay preset-only — they do
|
||||
// not switch you to that model.
|
||||
// Editing always records the model's global preset (keyed by provider::model,
|
||||
// not per-surface — a tile edit re-applies to that model everywhere); the
|
||||
// active model also gets it pushed onto its OWN session (primary → globals,
|
||||
// tile → its slice). Non-active edits stay preset-only — no model switch.
|
||||
const patchReasoning = async (next: string) => {
|
||||
setModelPreset(provider, model, { effort: next })
|
||||
|
||||
|
|
@ -125,8 +125,12 @@ export function ModelEditSubmenu({
|
|||
return
|
||||
}
|
||||
|
||||
markComposerSelectionManual()
|
||||
setCurrentReasoningEffort(next)
|
||||
if (touchesPrimary) {
|
||||
markComposerSelectionManual()
|
||||
setCurrentReasoningEffort(next)
|
||||
} else if (activeSessionId) {
|
||||
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next }))
|
||||
}
|
||||
|
||||
// Preset-only without a session: `isActive` holds for the global/default
|
||||
// row pre-session, and the gateway's `config.set` falls back to global
|
||||
|
|
@ -139,7 +143,12 @@ export function ModelEditSubmenu({
|
|||
try {
|
||||
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
|
||||
} catch (err) {
|
||||
setCurrentReasoningEffort(effort)
|
||||
if (touchesPrimary) {
|
||||
setCurrentReasoningEffort(effort)
|
||||
} else if (activeSessionId) {
|
||||
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: effort }))
|
||||
}
|
||||
|
||||
setModelPreset(provider, model, { effort })
|
||||
notifyError(err, copy.updateFailed)
|
||||
}
|
||||
|
|
@ -167,8 +176,12 @@ export function ModelEditSubmenu({
|
|||
return
|
||||
}
|
||||
|
||||
markComposerSelectionManual()
|
||||
setCurrentFastMode(enabled)
|
||||
if (touchesPrimary) {
|
||||
markComposerSelectionManual()
|
||||
setCurrentFastMode(enabled)
|
||||
} else if (activeSessionId) {
|
||||
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled }))
|
||||
}
|
||||
|
||||
// Preset-only without a session (see patchReasoning).
|
||||
if (!activeSessionId) {
|
||||
|
|
@ -182,7 +195,12 @@ export function ModelEditSubmenu({
|
|||
value: enabled ? 'fast' : 'normal'
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentFastMode(!enabled)
|
||||
if (touchesPrimary) {
|
||||
setCurrentFastMode(!enabled)
|
||||
} else if (activeSessionId) {
|
||||
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled }))
|
||||
}
|
||||
|
||||
setModelPreset(provider, model, { fast: !enabled })
|
||||
notifyError(err, copy.fastFailed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ beforeAll(() => {
|
|||
const getGlobalModelOptions = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args)
|
||||
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
|
||||
setApiRequestProfile: vi.fn()
|
||||
}))
|
||||
|
||||
// MoA presets now arrive as the catalog's virtual `moa` provider row (the same
|
||||
|
|
@ -64,7 +65,7 @@ describe('ModelMenuPanel MoA presets', () => {
|
|||
// #54670: must route through the persistent model-switch path
|
||||
// i.e. onSelectModel with provider 'moa' (which session-scopes live-session
|
||||
// switches), NOT a one-shot command.dispatch that reverts after a turn.
|
||||
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' })
|
||||
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa', sessionId: 'runtime-1' })
|
||||
})
|
||||
|
||||
it('shows the check on the preset that matches the current moa selection', async () => {
|
||||
|
|
@ -103,6 +104,6 @@ describe('ModelMenuPanel MoA presets', () => {
|
|||
|
||||
// Pre-session picks are UI state shipped on the next session.create — the
|
||||
// row must not be disabled and must still route through onSelectModel.
|
||||
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' })
|
||||
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa', sessionId: null })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
|
|
@ -36,13 +37,6 @@ import {
|
|||
modelVisibilityKey,
|
||||
setModelVisibilityOpen
|
||||
} from '@/store/model-visibility'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort
|
||||
} from '@/store/session'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
||||
|
|
@ -52,9 +46,17 @@ import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
|||
// (reasoning/fast) stays open to play with (its items preventDefault on select).
|
||||
export const ModelMenuCloseContext = createContext<() => void>(() => {})
|
||||
|
||||
export interface ModelSelection {
|
||||
model: string
|
||||
provider: string
|
||||
/** Runtime id of the surface that opened the menu. When set, the switch
|
||||
* targets that session (a tile) instead of the primary `$activeSessionId`. */
|
||||
sessionId?: null | string
|
||||
}
|
||||
|
||||
interface ModelMenuPanelProps {
|
||||
gateway?: HermesGateway
|
||||
onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
|
||||
onSelectModel: (selection: ModelSelection) => Promise<boolean> | void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
|
|
@ -70,14 +72,14 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
const [search, setSearch] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
// Reactive session state is read from the stores here (not drilled in), so
|
||||
// toggling effort/fast/model re-renders this panel in place without forcing
|
||||
// the parent to rebuild the menu content (which would close the dropdown).
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
// Bind to THIS surface's SessionView (primary or tile) so each pane's menu
|
||||
// shows/switches its own model — not the primary-only globals.
|
||||
const view = useSessionView()
|
||||
const activeSessionId = useStore(view.$runtimeId)
|
||||
const currentFastMode = useStore(view.$fast)
|
||||
const currentModel = useStore(view.$model)
|
||||
const currentProvider = useStore(view.$provider)
|
||||
const currentReasoningEffort = useStore(view.$reasoningEffort)
|
||||
const modelPresets = useStore($modelPresets)
|
||||
const visibleModels = useStore($visibleModels)
|
||||
|
||||
|
|
@ -126,7 +128,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
// The composer picker never persists the profile default. With a session it
|
||||
// scopes the switch to that session; with none it's UI state shipped on the
|
||||
// next session.create (see selectModel). The default lives in Settings → Model.
|
||||
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
|
||||
// Always stamp sessionId from this surface so a tile switch never hits the
|
||||
// primary (busy) session by accident.
|
||||
const switchTo = (model: string, provider: string) =>
|
||||
onSelectModel({ model, provider, sessionId: activeSessionId || null })
|
||||
|
||||
// Explicit "Refresh Models": re-fetch the catalog with refresh:true so the
|
||||
// backend busts its 1h provider-model disk cache and re-pulls each provider's
|
||||
|
|
@ -175,7 +180,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
|
||||
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
|
||||
},
|
||||
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
|
||||
{
|
||||
failMessage: t.shell.modelOptions.updateFailed,
|
||||
primary: view.kind === 'primary',
|
||||
request: requestGateway,
|
||||
sessionId: activeSessionId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { forceLoneHeaderForPanes } from './lone-header'
|
||||
|
||||
describe('forceLoneHeaderForPanes', () => {
|
||||
const chrome =
|
||||
(placement?: string, uncloseable = false) =>
|
||||
() => ({ placement, uncloseable })
|
||||
const noCollapse = () => false
|
||||
|
||||
it('forces a header for session-tile ids even without registered chrome', () => {
|
||||
expect(forceLoneHeaderForPanes(['session-tile:abc'], () => ({}), noCollapse)).toBe(true)
|
||||
})
|
||||
|
||||
it('forces a header for closeable placement:main panes', () => {
|
||||
expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
|
||||
expect(forceLoneHeaderForPanes(['some-page'], chrome('main', false), noCollapse)).toBe(true)
|
||||
})
|
||||
|
||||
it('forces a header for a lone collapse tool pane', () => {
|
||||
expect(
|
||||
forceLoneHeaderForPanes(
|
||||
['terminal'],
|
||||
() => ({}),
|
||||
id => id === 'terminal'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a lone uncloseable workspace headerless', () => {
|
||||
expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* When a lone pane must keep its tab strip (name card + close).
|
||||
*
|
||||
* Default: a single pane isn't a "tab", so the header auto-hides. Exceptions
|
||||
* force it on so a closeable surface never becomes an unclosable dead zone:
|
||||
* - session tiles (`session-tile:*`) — even before chrome registers
|
||||
* - any closeable `placement: 'main'` contribution
|
||||
* - a collapse tool panel dragged into its own zone
|
||||
*/
|
||||
|
||||
export interface LoneHeaderChrome {
|
||||
placement?: string
|
||||
uncloseable?: boolean
|
||||
}
|
||||
|
||||
export function forceLoneHeaderForPanes(
|
||||
shown: readonly string[],
|
||||
chromeOf: (id: string) => LoneHeaderChrome,
|
||||
isCollapsePane: (id: string) => boolean
|
||||
): boolean {
|
||||
if (shown.some(id => id.startsWith('session-tile:'))) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
shown.some(id => {
|
||||
const chrome = chromeOf(id)
|
||||
|
||||
return !chrome.uncloseable && chrome.placement === 'main'
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return shown.length === 1 && isCollapsePane(shown[0])
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ import {
|
|||
} from '../store'
|
||||
|
||||
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
||||
import { forceLoneHeaderForPanes } from './lone-header'
|
||||
import { paneChrome } from './track-model'
|
||||
|
||||
/** A directional action in the zone menu (computed per group state). */
|
||||
|
|
@ -187,13 +188,9 @@ export function TreeGroup({
|
|||
// The uncloseable workspace and side chrome (sessions/files) keep the clean
|
||||
// no-tab default. Double-click toggles it either way; a minimized group
|
||||
// always shows its header (it IS the header).
|
||||
const forceLoneHeader =
|
||||
shown.some(id => {
|
||||
const chrome = paneChrome(paneFor(id))
|
||||
|
||||
return !chrome.uncloseable && chrome.placement === 'main'
|
||||
}) ||
|
||||
(shown.length === 1 && isCollapsePane(shown[0]))
|
||||
// Session-tile ids force the header even before chrome registers — cycling
|
||||
// onto a freshly-split tile used to land headerless ("name card missing").
|
||||
const forceLoneHeader = forceLoneHeaderForPanes(shown, id => paneChrome(paneFor(id)), isCollapsePane)
|
||||
|
||||
// A full-page view (headerVeto) suppresses the strip while it's the active
|
||||
// pane — a page is not a tab-able surface; the bar returns with the chat.
|
||||
|
|
|
|||
|
|
@ -375,7 +375,15 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
|||
}
|
||||
|
||||
const idx = Math.max(0, panes.indexOf(group!.active ?? ''))
|
||||
activateTreePane(group!.id, panes[(idx + direction + panes.length) % panes.length])
|
||||
const nextId = panes[(idx + direction + panes.length) % panes.length]
|
||||
activateTreePane(group!.id, nextId)
|
||||
|
||||
// Cycling onto a session/main tab must surface the name card — a zone that
|
||||
// was double-tap-hidden stays headerless otherwise ("the one that cycles
|
||||
// never gets it").
|
||||
if (nextId === 'workspace' || nextId.startsWith('session-tile:')) {
|
||||
setTreeGroupHeaderHidden(group!.id, false)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { persistString, storedString } from '@/lib/storage'
|
|||
|
||||
import { notifyError } from './notifications'
|
||||
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
|
||||
import { sessionTileDelegate } from './session-states'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.model-presets'
|
||||
|
||||
|
|
@ -55,17 +56,28 @@ export function setModelPreset(provider: string, model: string, patch: ModelPres
|
|||
* `undefined` skips that dimension; values are capability-gated upstream.
|
||||
* Without a session the local draft still needs the preset, but must not call
|
||||
* `config.set`: that falls back to persistent profile config when no session
|
||||
* matches and would rewrite the user's defaults. */
|
||||
* matches and would rewrite the user's defaults.
|
||||
*
|
||||
* `primary: false` scopes the optimistic write to the tile's session slice —
|
||||
* a tile's picker must not clobber the primary composer's effort/fast. */
|
||||
export async function applyModelPreset(
|
||||
{ effort, fast }: ModelPreset,
|
||||
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
|
||||
ctx: { failMessage: string; primary?: boolean; request: RequestGateway; sessionId: null | string }
|
||||
): Promise<void> {
|
||||
if (effort !== undefined) {
|
||||
setCurrentReasoningEffort(effort)
|
||||
}
|
||||
if (ctx.primary ?? true) {
|
||||
if (effort !== undefined) {
|
||||
setCurrentReasoningEffort(effort)
|
||||
}
|
||||
|
||||
if (fast !== undefined) {
|
||||
setCurrentFastMode(fast)
|
||||
if (fast !== undefined) {
|
||||
setCurrentFastMode(fast)
|
||||
}
|
||||
} else if (ctx.sessionId) {
|
||||
sessionTileDelegate()?.updateSession(ctx.sessionId, state => ({
|
||||
...state,
|
||||
...(effort !== undefined ? { reasoningEffort: effort } : {}),
|
||||
...(fast !== undefined ? { fast } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
if (!ctx.sessionId) {
|
||||
|
|
|
|||
46
apps/desktop/src/store/session-states.test.ts
Normal file
46
apps/desktop/src/store/session-states.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { group, split } from '@/components/pane-shell/tree/model'
|
||||
import type { SessionTile } from '@/store/session-states'
|
||||
import { orderTilesByTree, selectionHomesToWorkspace } from '@/store/session-states'
|
||||
|
||||
const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
|
||||
const tilePane = (id: string) => `session-tile:${id}`
|
||||
|
||||
describe('orderTilesByTree', () => {
|
||||
it('no-ops (null) without a tree or below two tiles', () => {
|
||||
expect(orderTilesByTree(null, [tile('a'), tile('b')])).toBeNull()
|
||||
expect(orderTilesByTree(group([tilePane('a')]), [tile('a')])).toBeNull()
|
||||
})
|
||||
|
||||
it('reorders tiles to layout-tree encounter order across a split', () => {
|
||||
const tree = split('row', [group(['workspace', tilePane('b')]), group([tilePane('a')])])
|
||||
|
||||
expect(orderTilesByTree(tree, [tile('a'), tile('b')])).toEqual([tile('b'), tile('a')])
|
||||
})
|
||||
|
||||
it('returns null when the array already matches strip order (skip persist)', () => {
|
||||
const tree = split('row', [group([tilePane('b')]), group([tilePane('a')])])
|
||||
|
||||
expect(orderTilesByTree(tree, [tile('b'), tile('a')])).toBeNull()
|
||||
})
|
||||
|
||||
it('sorts not-yet-adopted tiles after placed ones, stably', () => {
|
||||
const tree = group(['workspace', tilePane('b')])
|
||||
|
||||
expect(orderTilesByTree(tree, [tile('a'), tile('b'), tile('c')])).toEqual([tile('b'), tile('a'), tile('c')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectionHomesToWorkspace', () => {
|
||||
const tiles = [tile('a'), tile('b')]
|
||||
|
||||
it('homes for a null selection or a non-tile session', () => {
|
||||
expect(selectionHomesToWorkspace(null, tiles)).toBe(true)
|
||||
expect(selectionHomesToWorkspace('c', tiles)).toBe(true)
|
||||
})
|
||||
|
||||
it('skips homing when the selected id is already an open tile', () => {
|
||||
expect(selectionHomesToWorkspace('a', tiles)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
import { findGroup, findGroupOfPane, type LayoutNode } from '@/components/pane-shell/tree/model'
|
||||
import {
|
||||
$activeTreeGroup,
|
||||
$layoutTree,
|
||||
|
|
@ -250,10 +250,11 @@ export interface SessionTile {
|
|||
/** Dock against `anchor` on adoption (default right; center = stack). */
|
||||
dir?: TileDock
|
||||
/** Pane to dock against (a drop's target zone) — default the workspace.
|
||||
* In-memory only: after first adoption the tree remembers placement. */
|
||||
* Persisted so a restart re-docks in place; a stale id falls back to the
|
||||
* workspace (findGroupOfPane misses → the move is skipped). */
|
||||
anchor?: string
|
||||
/** Center docks: stack BEFORE this pane id (`null`/omitted = append) —
|
||||
* the strip divider's slot. In-memory, like `anchor`. */
|
||||
/** Center docks: stack BEFORE this pane id (`null`/omitted = append) — the
|
||||
* strip divider's slot. Persisted, like `anchor`; a stale id appends. */
|
||||
before?: null | string
|
||||
/** Live runtime id once the tile's resume has bound one. */
|
||||
runtimeId?: string
|
||||
|
|
@ -269,16 +270,34 @@ export interface SessionTile {
|
|||
// "stale runtime after respawn" bugs by construction).
|
||||
const TILES_KEY = 'hermes.desktop.sessionTiles.v2'
|
||||
const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1'
|
||||
const TILE_PANE_PREFIX = 'session-tile:'
|
||||
|
||||
type StoredTile = Pick<SessionTile, 'dir' | 'storedSessionId'>
|
||||
/** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a
|
||||
* restart / profile swap re-adopts tiles in the same order, not all stacked
|
||||
* right of workspace. */
|
||||
type StoredTile = Pick<SessionTile, 'anchor' | 'before' | 'dir' | 'storedSessionId'>
|
||||
|
||||
const toStored = (t: SessionTile): StoredTile => ({ dir: t.dir, storedSessionId: t.storedSessionId })
|
||||
const toStored = (t: SessionTile): StoredTile => ({
|
||||
anchor: t.anchor,
|
||||
before: t.before,
|
||||
dir: t.dir,
|
||||
storedSessionId: t.storedSessionId
|
||||
})
|
||||
|
||||
function parseTileList(value: unknown): StoredTile[] {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((t): t is SessionTile => Boolean(t && typeof (t as SessionTile).storedSessionId === 'string'))
|
||||
.map(toStored)
|
||||
.map(t => {
|
||||
const raw = t as SessionTile
|
||||
|
||||
return {
|
||||
anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined,
|
||||
before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined,
|
||||
dir: raw.dir,
|
||||
storedSessionId: raw.storedSessionId
|
||||
}
|
||||
})
|
||||
: []
|
||||
}
|
||||
|
||||
|
|
@ -407,6 +426,56 @@ export function sessionTileDelegate(): SessionTileDelegate | null {
|
|||
return delegate
|
||||
}
|
||||
|
||||
/** Reorder tiles to match layout-tree encounter order (stored ids in the order
|
||||
* their `session-tile:` panes are walked). Restore replays the array through
|
||||
* sequential adoption (each center tile APPENDS after the ones before it), so
|
||||
* array order IS strip order — no `before` stamping needed; a stale `before`
|
||||
* naming an absent pane falls back to append anyway (see insertAtGroup). Tiles
|
||||
* not yet adopted sort after placed ones, stably. Returns `null` when nothing
|
||||
* moves so callers can skip a needless persist. */
|
||||
export function orderTilesByTree<T extends { storedSessionId: string }>(
|
||||
tree: LayoutNode | null,
|
||||
tiles: readonly T[]
|
||||
): null | T[] {
|
||||
if (!tree || tiles.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const order: string[] = []
|
||||
|
||||
const walk = (node: LayoutNode) => {
|
||||
if (node.type === 'group') {
|
||||
for (const id of node.panes) {
|
||||
if (id.startsWith(TILE_PANE_PREFIX)) {
|
||||
order.push(id.slice(TILE_PANE_PREFIX.length))
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
node.children.forEach(walk)
|
||||
}
|
||||
|
||||
walk(tree)
|
||||
|
||||
const rank = new Map(order.map((id, i) => [id, i]))
|
||||
|
||||
const next = [...tiles].sort(
|
||||
(a, b) => (rank.get(a.storedSessionId) ?? Infinity) - (rank.get(b.storedSessionId) ?? Infinity)
|
||||
)
|
||||
|
||||
return next.some((t, i) => t !== tiles[i]) ? next : null
|
||||
}
|
||||
|
||||
function syncTileStripOrder() {
|
||||
const next = orderTilesByTree($layoutTree.get(), $sessionTiles.get())
|
||||
|
||||
if (next) {
|
||||
saveTiles(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a tile for a stored session, or MOVE an existing one to the new dock
|
||||
* (`dir`; `center` = stack into the anchor's zone, `before` = strip slot). The
|
||||
* move path is what lets a tile's own TAB be dragged like a sidebar row — drop
|
||||
|
|
@ -427,6 +496,8 @@ export function openSessionTile(
|
|||
|
||||
if (!tiles.some(t => t.storedSessionId === storedSessionId)) {
|
||||
saveTiles([...tiles, { anchor, before, dir, storedSessionId }])
|
||||
// Adoption is async via the registry — order sync runs after the move path
|
||||
// below; a brand-new tile's strip slot is already in `before`.
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -438,6 +509,8 @@ export function openSessionTile(
|
|||
|
||||
if (target) {
|
||||
moveTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`, { before: before ?? null, groupId: target, pos: dir })
|
||||
patchSessionTile(storedSessionId, { anchor, before: before ?? undefined, dir })
|
||||
syncTileStripOrder()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -532,8 +605,6 @@ export function reopenLastClosedTile(): void {
|
|||
// timer / model) reads these instead of the primary-only atoms.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TILE_PANE_PREFIX = 'session-tile:'
|
||||
|
||||
/** Stored id of the focused session (the interacted zone's tile, else the
|
||||
* primary's selection). Null on a fresh draft. */
|
||||
export const $focusedStoredSessionId = computed(
|
||||
|
|
@ -563,12 +634,20 @@ export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates]
|
|||
runtimeId ? states[runtimeId] : undefined
|
||||
)
|
||||
|
||||
// A PRIMARY navigation (sidebar resume, route change, new chat) moves focus
|
||||
// home to the workspace — a previously-clicked tile must not keep owning the
|
||||
// titlebar/statusbar readouts for a session switch it had no part in. It also
|
||||
// FRONTS the workspace tab: the resumed chat loads in the workspace pane, so a
|
||||
// zone parked on a tile tab must switch back or the click looks dead.
|
||||
$selectedStoredSessionId.listen(() => {
|
||||
/** A PRIMARY navigation (sidebar resume, route change, new chat) homes focus to
|
||||
* the workspace — UNLESS the selected id is already an open TILE, where
|
||||
* `focusOpenSession` owns the move and homing would yank every stacked tile
|
||||
* behind the workspace (A+B "disappear" when switching to C). */
|
||||
export const selectionHomesToWorkspace = (selected: null | string, tiles: readonly SessionTile[]): boolean =>
|
||||
!(selected && tiles.some(t => t.storedSessionId === selected))
|
||||
|
||||
// Homing also FRONTS the workspace tab: the resumed chat loads in the workspace
|
||||
// pane, so a zone parked on a tile tab must switch back or the click looks dead.
|
||||
$selectedStoredSessionId.listen(selected => {
|
||||
if (!selectionHomesToWorkspace(selected, $sessionTiles.get())) {
|
||||
return
|
||||
}
|
||||
|
||||
noteActiveTreeGroup(null)
|
||||
revealTreePane('workspace')
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue