From 1ac3f97fbe0958aa2743c0c26202b20b3b4e760e Mon Sep 17 00:00:00 2001 From: andyst-dev <150129844+andyst-dev@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:19:17 +0200 Subject: [PATCH 1/5] fix(dashboard): enable touch scrollback in chat --- web/src/lib/chat-panel-collapsed.ts | 21 ++++ web/src/lib/terminal-touch-scroll.test.ts | 70 +++++++++++++ web/src/lib/terminal-touch-scroll.ts | 114 ++++++++++++++++++++++ web/src/pages/ChatPage.tsx | 15 ++- 4 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/chat-panel-collapsed.ts create mode 100644 web/src/lib/terminal-touch-scroll.test.ts create mode 100644 web/src/lib/terminal-touch-scroll.ts diff --git a/web/src/lib/chat-panel-collapsed.ts b/web/src/lib/chat-panel-collapsed.ts new file mode 100644 index 0000000000..da03a70bcf --- /dev/null +++ b/web/src/lib/chat-panel-collapsed.ts @@ -0,0 +1,21 @@ +/** + * Collapse state for the desktop chat side panel (model + sessions), + * persisted in localStorage so the choice survives reloads. + * + * Pure helpers so ChatPage stays under its FILE_LINES cap and the + * persistence stays testable without React. + */ + +const CHAT_PANEL_COLLAPSED_KEY = "hermes-chat-panel-collapsed"; + +/** The panel's collapsed flag remembered from the previous visit. */ +export function readChatPanelCollapsed(): boolean { + return localStorage.getItem(CHAT_PANEL_COLLAPSED_KEY) === "1"; +} + +/** Flip the collapsed flag and persist the new choice. */ +export function toggleChatPanelCollapsed(previous: boolean): boolean { + const next = !previous; + localStorage.setItem(CHAT_PANEL_COLLAPSED_KEY, next ? "1" : "0"); + return next; +} diff --git a/web/src/lib/terminal-touch-scroll.test.ts b/web/src/lib/terminal-touch-scroll.test.ts new file mode 100644 index 0000000000..c22406830c --- /dev/null +++ b/web/src/lib/terminal-touch-scroll.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +import { installTerminalTouchScroll } from "./terminal-touch-scroll"; + +function touchEvent(type: string, y: number): TouchEvent { + const ev = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent; + const touch = { clientY: y } as Touch; + + Object.defineProperty(ev, "touches", { value: type === "touchend" ? [] : [touch] }); + Object.defineProperty(ev, "changedTouches", { value: [touch] }); + + return ev; +} + +function term(viewportY: number, baseY: number) { + const terminal = { + buffer: { active: { baseY, viewportY } }, + scrollLines: vi.fn((amount: number) => { + const active = terminal.buffer.active; + active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + amount)); + }), + }; + + return terminal; +} + +describe("installTerminalTouchScroll", () => { + it("converts one-finger vertical drags into xterm scrollback lines", () => { + const host = new EventTarget() as HTMLElement; + const terminal = term(20, 100); + const cleanup = installTerminalTouchScroll(host, terminal); + + host.dispatchEvent(touchEvent("touchstart", 200)); + const move = touchEvent("touchmove", 164); + host.dispatchEvent(move); + + expect(terminal.scrollLines).toHaveBeenCalledWith(2); + expect(move.defaultPrevented).toBe(true); + + cleanup(); + }); + + it("lets the outer page handle touch drags at the terminal boundary", () => { + const host = new EventTarget() as HTMLElement; + const terminal = term(0, 100); + const cleanup = installTerminalTouchScroll(host, terminal); + + host.dispatchEvent(touchEvent("touchstart", 100)); + const move = touchEvent("touchmove", 136); + host.dispatchEvent(move); + + expect(terminal.scrollLines).not.toHaveBeenCalled(); + expect(move.defaultPrevented).toBe(false); + + cleanup(); + }); + + it("removes listeners on cleanup", () => { + const host = new EventTarget() as HTMLElement; + const terminal = term(20, 100); + const cleanup = installTerminalTouchScroll(host, terminal); + + cleanup(); + + host.dispatchEvent(touchEvent("touchstart", 200)); + host.dispatchEvent(touchEvent("touchmove", 164)); + + expect(terminal.scrollLines).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/terminal-touch-scroll.ts b/web/src/lib/terminal-touch-scroll.ts new file mode 100644 index 0000000000..818ed834e0 --- /dev/null +++ b/web/src/lib/terminal-touch-scroll.ts @@ -0,0 +1,114 @@ +interface ScrollableTerminal { + buffer: { + active: { + baseY: number; + viewportY: number; + }; + }; + scrollLines: (amount: number) => void; +} + +const TOUCH_SCROLL_PX_PER_LINE = 18; +const TOUCH_SCROLL_MIN_PX = 2; +const CAPTURE = { capture: true } as const; +const PASSIVE_CAPTURE = { capture: true, passive: true } as const; +const ACTIVE_CAPTURE = { capture: true, passive: false } as const; + +function firstTouchY(ev: TouchEvent): number | null { + const touch = ev.touches[0] ?? ev.changedTouches[0]; + + return touch ? touch.clientY : null; +} + +function canScrollTerminal(term: ScrollableTerminal, amount: number): boolean { + const { baseY, viewportY } = term.buffer.active; + + if (amount < 0) { + return viewportY > 0; + } + + if (amount > 0) { + return viewportY < baseY; + } + + return false; +} + +/** + * Add touch-drag scrollback support for dashboard xterm panes. + * + * xterm.js handles wheel/trackpad scrollback for the embedded TUI, but iPadOS + * WebKit does not translate a finger drag inside the terminal into wheel events + * or native scrollbar dragging. Mirror the wheel path by converting vertical + * touch movement into `scrollLines()` while there is xterm scrollback available; + * at the top/bottom boundary we leave the event alone so the outer dashboard + * page can still move above the software keyboard. + */ +export function installTerminalTouchScroll(host: HTMLElement, term: ScrollableTerminal): () => void { + let lastY: number | null = null; + let residualPx = 0; + + const reset = () => { + lastY = null; + residualPx = 0; + }; + + const onTouchStart = (ev: TouchEvent) => { + if (ev.touches.length !== 1) { + reset(); + return; + } + + lastY = firstTouchY(ev); + residualPx = 0; + }; + + const onTouchMove = (ev: TouchEvent) => { + if (ev.touches.length !== 1 || lastY === null) { + reset(); + return; + } + + const nextY = firstTouchY(ev); + if (nextY === null) { + reset(); + return; + } + + const deltaPx = lastY - nextY; + lastY = nextY; + + if (Math.abs(deltaPx) < TOUCH_SCROLL_MIN_PX) { + return; + } + + residualPx += deltaPx; + const lines = residualPx / TOUCH_SCROLL_PX_PER_LINE; + const wholeLines = lines < 0 ? Math.ceil(lines) : Math.floor(lines); + + if (wholeLines === 0) { + return; + } + + if (!canScrollTerminal(term, wholeLines)) { + return; + } + + residualPx -= wholeLines * TOUCH_SCROLL_PX_PER_LINE; + term.scrollLines(wholeLines); + ev.preventDefault(); + ev.stopPropagation(); + }; + + host.addEventListener("touchstart", onTouchStart, PASSIVE_CAPTURE); + host.addEventListener("touchmove", onTouchMove, ACTIVE_CAPTURE); + host.addEventListener("touchcancel", reset, CAPTURE); + host.addEventListener("touchend", reset, CAPTURE); + + return () => { + host.removeEventListener("touchstart", onTouchStart, PASSIVE_CAPTURE); + host.removeEventListener("touchmove", onTouchMove, ACTIVE_CAPTURE); + host.removeEventListener("touchcancel", reset, CAPTURE); + host.removeEventListener("touchend", reset, CAPTURE); + }; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 328b99ba38..9baf930498 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -37,6 +37,7 @@ import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; import { readStoredWorkspace, writeStoredWorkspace } from "@/lib/chat-workspaces"; import { latchChatActivation } from "@/lib/chat-activation"; +import { readChatPanelCollapsed, toggleChatPanelCollapsed } from "@/lib/chat-panel-collapsed"; import { copyTextToClipboard } from "@/lib/clipboard"; import { normalizeSessionTitle } from "@/lib/chat-title"; import { createPtyCompositionForwarder } from "@/lib/pty-composition"; @@ -76,6 +77,7 @@ import { parseResumeControlMessage, shouldFollowPtyOutput, } from "@/lib/pty-scroll"; +import { installTerminalTouchScroll } from "@/lib/terminal-touch-scroll"; import { uploadChatImage } from "@/lib/chatImagePaste"; import { attachChatImageDropListeners } from "@/lib/chat-image-drop"; import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload"; @@ -198,8 +200,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const connectingTimerRef = useRef | null>(null); const ptyInputLineRef = useRef(""); const mobileReplacementInputUntilRef = useRef(0); - const [ptyState, setPtyState] = - useState("connecting"); + const [ptyState, setPtyState] = useState("connecting"); const ptyStateRef = useRef("connecting"); // True until the first real PTY payload arrives for a resumed session. // Covers the blank terminal + blinking-cursor window so users don't think @@ -288,14 +289,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // Collapse toggle for the desktop chat side panel (model + sessions), // persisted in localStorage so the choice survives reloads. const [chatPanelCollapsed, setChatPanelCollapsed] = useState( - () => localStorage.getItem("hermes-chat-panel-collapsed") === "1", + readChatPanelCollapsed, ); const toggleChatPanel = useCallback(() => { - setChatPanelCollapsed((prev) => { - const next = !prev; - localStorage.setItem("hermes-chat-panel-collapsed", next ? "1" : "0"); - return next; - }); + setChatPanelCollapsed(toggleChatPanelCollapsed); }, []); const { setEnd, setTitle } = usePageHeader(); const [sessionTitleState, setSessionTitleState] = useState<{ @@ -783,6 +780,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { ev.stopPropagation(); return false; }); + const cleanupTouchScroll = installTerminalTouchScroll(host, term); const unicode11 = new Unicode11Addon(); term.loadAddon(unicode11); @@ -1557,6 +1555,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { onScrollDisposable?.dispose(); mobileInputCleanup?.(); compositionForwarder.dispose(); + cleanupTouchScroll(); handleBrowserDropCleanup(); if (metricsDebounce) clearTimeout(metricsDebounce); window.removeEventListener("resize", scheduleSyncTerminalMetrics); From 19c862020b806757addfb5dda3320457d0f0557a Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 7 Oct 2026 01:13:06 +0530 Subject: [PATCH 2/5] fix(desktop): Simple mode keeps the statusbar up for a signed-out free-tier user Simple's policy rested the whole statusbar hidden, and the bar's Sign in chip is the only standing way a free-tier user can sign in. Picking the Basic layout (Simple) left them with no visible Sign in. - $freeTierSignInOpen (free tier available, i.e. signed out, and no guided setup running) is the one condition: the chip shows on it, and the shell feeds it into the mode context. - Simple's statusbarVisible becomes context => context.freeTierSignInOpen, the same shape as profileRailVisible. Signed-in Simple users keep a hidden bar. - Replace the stale comment that pointed at the old onboarding ready screen. - Test: in every interface mode a signed-out free-tier user sees the chip. --- apps/desktop/src/app/contrib/controller.tsx | 3 + .../shell/hooks/use-statusbar-items.test.tsx | 75 ++++++++++++++++++- .../app/shell/hooks/use-statusbar-items.tsx | 18 ++--- apps/desktop/src/store/free-tier.ts | 14 +++- apps/desktop/src/store/interface-mode.ts | 15 +++- 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 1ac6c52048..9e204d62e2 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -59,6 +59,7 @@ import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from import { setYoloEnabled } from '@/lib/yolo-session' import { $connectionsRegistry } from '@/store/connection-registry-state' import { watchDeadSessionPrune } from '@/store/dead-session-prune' +import { $freeTierSignInOpen } from '@/store/free-tier' import { $interfaceMode, $showsAdvancedChrome, setModeContext, toggleSimpleMode } from '@/store/interface-mode' import { $fileBrowserOpen, @@ -633,6 +634,8 @@ bindToolPaneCollapse( // Without the statusbar, the rail is the only way to switch profiles or gateways. $profiles.subscribe(profiles => setModeContext({ profileCount: profiles.length })) $connectionsRegistry.subscribe(registry => setModeContext({ connectionCount: registry?.connections.length ?? 0 })) +// Without the statusbar, a signed-out free-tier user would have no standing Sign in. +$freeTierSignInOpen.subscribe(open => setModeContext({ freeTierSignInOpen: open })) // ⌘K door onto the same pane the keybind and statusbar pill flip. registry.register(terminalPaletteToggle) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx index 7cd7325d33..d565134d7f 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx @@ -2,11 +2,14 @@ import { renderHook } from '@testing-library/react' import type { WritableAtom } from 'nanostores' import { isValidElement, type ReactNode } from 'react' import { MemoryRouter } from 'react-router' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { group } from '@/components/pane-shell/tree/model' import { $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store' import { createClientSessionState } from '@/lib/chat-runtime' +import { $freeTierSignInOpen, $freeTierStatus } from '@/store/free-tier' +import { INTERFACE_MODES, setInterfaceMode, setModeContext } from '@/store/interface-mode' +import { $onboardingGate } from '@/store/onboarding-gate' import { $connection, $currentCwd, @@ -18,6 +21,8 @@ import { } from '@/store/session' import { $focusedTreePaneId as $focusedTreePaneIdMock } from '@/store/session-focus' import { $sessionStates, $sessionTiles } from '@/store/session-states' +import { $statusbarVisible } from '@/store/statusbar-prefs' +import type { FreeTierStatus } from '@/types/hermes' import { useStatusbarItems } from './use-statusbar-items' @@ -68,7 +73,7 @@ function focusPane(storedId: null | string): void { const wrapper = ({ children }: { children: ReactNode }) => {children} -function workspaceMenuIds(): string[] { +function renderStatusbarItems() { const { result } = renderHook( () => useStatusbarItems({ @@ -89,7 +94,11 @@ function workspaceMenuIds(): string[] { { wrapper } ) - const workspace = result.current.leftStatusbarItems.find(item => item.id === 'workspace-cwd') + return result.current +} + +function workspaceMenuIds(): string[] { + const workspace = renderStatusbarItems().leftStatusbarItems.find(item => item.id === 'workspace-cwd') return (workspace?.menuItems ?? []).map(item => item.id) } @@ -263,3 +272,63 @@ describe('useStatusbarItems session timer — runtime cache anchor', () => { expect(since).not.toBe(parentRowStartedAt * 1000) }) }) + +// The statusbar chip is a free-tier user's standing way in to a sign-in, and +// an interface mode decides whether the bar is mounted at all. Whatever a mode +// rests, a signed-out free-tier user keeps one visible Sign in. +describe('free-tier Sign in in every interface mode', () => { + const freeTier = (available: boolean): FreeTierStatus => ({ + available, + enabled: true, + has_guest: available, + label: 'Nous · free tier', + model: 'nous/welcome', + notice_pending: false + }) + + const desktopBridge = window.hermesDesktop + // The shell feeds the mode context from the same atom (app/contrib/controller.tsx). + const unwire = $freeTierSignInOpen.subscribe(open => setModeContext({ freeTierSignInOpen: open })) + + function signInChipVisible(): boolean { + const { leftStatusbarItems, statusbarItems } = renderStatusbarItems() + const chip = [...leftStatusbarItems, ...statusbarItems].find(item => item.id === 'free-tier') + + return $statusbarVisible.get() && chip !== undefined && !chip.hidden + } + + afterEach(() => { + $freeTierStatus.set(null) + $onboardingGate.set({ ...$onboardingGate.get(), phase: 'idle' }) + window.hermesDesktop = desktopBridge + setInterfaceMode('advanced') + }) + + afterAll(() => unwire()) + + it.each(INTERFACE_MODES)('shows the Sign in chip to a signed-out free-tier user in %s mode', mode => { + setInterfaceMode(mode) + $freeTierStatus.set(freeTier(true)) + + expect(signInChipVisible()).toBe(true) + }) + + it('lets Simple rest the bar again once the user has signed in', () => { + setInterfaceMode('simple') + $freeTierStatus.set(freeTier(true)) + expect($statusbarVisible.get()).toBe(true) + + $freeTierStatus.set(freeTier(false)) + expect($statusbarVisible.get()).toBe(false) + }) + + it('holds the chip while a guided setup is running and brings it back after', () => { + window.hermesDesktop = { ...desktopBridge, guestOnboardingEnabled: true } as typeof desktopBridge + $freeTierStatus.set(freeTier(true)) + $onboardingGate.set({ ...$onboardingGate.get(), phase: 'guided' }) + expect(signInChipVisible()).toBe(false) + + $onboardingGate.set({ ...$onboardingGate.get(), phase: 'done' }) + expect(signInChipVisible()).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 77e64b918a..55c7fe59d6 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -39,11 +39,10 @@ import { cn } from '@/lib/utils' import { resolveVersionStatus } from '@/lib/version-status' import type { ApprovalModeRequester } from '@/store/approval-mode' import { copyFilePath, revealFile, shouldOfferLocalReveal } from '@/store/file-actions' -import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier' +import { $freeTierSignInOpen, $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier' import { openFreeTierSignIn } from '@/store/free-tier-sign-in' import { requestGatewayForProfile } from '@/store/gateway' import { revealFileInTree } from '@/store/layout' -import { $onboardingGate, guidedOnboardingActive } from '@/store/onboarding-gate' import { $activeGatewayProfile } from '@/store/profile' import { $profileRailVisible } from '@/store/profile-rail-prefs' import { $projectTree, projectNameForCwd } from '@/store/projects' @@ -159,12 +158,10 @@ export function useStatusbarItems({ // Backend truth for the free-tier chip. Refreshed on the ambient status // cadence (use-status-snapshot), never polled from here. const freeTier = useStore($freeTierStatus) - // The chip is a standing invitation to sign in. During the guided first - // launch that invitation lives on the guide's own ready screen; a second - // one in the statusbar is a distraction from the chat they are in. The - // subscription is what makes the check reactive. - useStore($onboardingGate) - const guideOwnsSignIn = guidedOnboardingActive() + // The chip is the standing invitation to sign in. It rests while a guided + // setup is running, so the chat the user is in has no second call to action, + // and comes back when the setup ends. + const freeTierSignInOpen = useStore($freeTierSignInOpen) const updateStatus = useStore($updateStatus) const updateApply = useStore($updateApply) const backendUpdateStatus = useStore($backendUpdateStatus) @@ -550,7 +547,7 @@ export function useStatusbarItems({ // Shown while a free-tier identity exists and the tier is on: it names the // identity that carries the connectors (and inference when nothing else // does), and it is the persistent way in to the sign-in. - hidden: !freeTier?.available || guideOwnsSignIn, + hidden: !freeTierSignInOpen, icon: , id: 'free-tier', label: freeTierCopy.providerName, @@ -656,9 +653,8 @@ export function useStatusbarItems({ fileMenu.revealFileManager, fileMenu.revealInSidebar, offerLocalReveal, - freeTier?.available, freeTier?.model, - guideOwnsSignIn, + freeTierSignInOpen, gatewayMenuContent, gatewayClassName, gatewayHealth, diff --git a/apps/desktop/src/store/free-tier.ts b/apps/desktop/src/store/free-tier.ts index 42924d4119..7ea8d7820f 100644 --- a/apps/desktop/src/store/free-tier.ts +++ b/apps/desktop/src/store/free-tier.ts @@ -1,6 +1,7 @@ -import { atom } from 'nanostores' +import { atom, computed } from 'nanostores' import { runFreeTierChallenge } from '@/store/free-tier-challenge' +import { $onboardingGate, guidedOnboardingActive } from '@/store/onboarding-gate' import { onboardingSurfaceActive } from '@/store/onboarding-presence' import type { FreeTierStatus } from '@/types/hermes' @@ -23,6 +24,17 @@ export type FreeTierRequester = (method: string, params?: Record(null) +/** + * A signed-out free-tier user who should see the standing Sign in: the tier is + * on with an identity (`available`; a sign-in replaces that identity, so it + * reads false afterwards) and no guided setup owns the moment. The statusbar + * chip shows on it, and Simple mode keeps the statusbar up on it. + */ +export const $freeTierSignInOpen = computed( + [$freeTierStatus, $onboardingGate], + status => Boolean(status?.available) && !guidedOnboardingActive() +) + function isFreeTierStatus(value: unknown): value is FreeTierStatus { return typeof value === 'object' && value !== null && typeof (value as FreeTierStatus).has_guest === 'boolean' } diff --git a/apps/desktop/src/store/interface-mode.ts b/apps/desktop/src/store/interface-mode.ts index cba772523d..e66930f37d 100644 --- a/apps/desktop/src/store/interface-mode.ts +++ b/apps/desktop/src/store/interface-mode.ts @@ -77,6 +77,9 @@ export function toggleSimpleMode() { /** What a policy may look at when its answer depends on the install. */ export interface ModeContext { connectionCount: number + /** A signed-out free-tier user with no setup in progress: the statusbar's + * Sign in chip is their standing way to sign in. */ + freeTierSignInOpen: boolean profileCount: number } @@ -111,7 +114,9 @@ const SIMPLE_POLICY: PolicyTable = { reviewOpen: false, // What was said and when — cost, tokens, PR and profile chips are readouts. sidebarRowMeta: ['preview', 'updated'], - statusbarVisible: false, + // The bar carries the free tier's Sign in chip, the one standing way in to a + // sign-in. It rests hidden for everyone else and goes away once they sign in. + statusbarVisible: context => context.freeTierSignInOpen, terminalOpen: false, // Product summaries; the technical payload view is the instrumentation itself. toolViewMode: 'product' @@ -127,10 +132,14 @@ const POLICY: Record = { /** Does this mode have an opinion about the surface at all? */ const shadows = (key: ModePolicyKey, mode: InterfaceMode) => key in POLICY[mode] -// The install facts a policy may consult. Fed by the app shell (profiles and gateways), +// The install facts a policy may consult. Fed by the app shell (profiles, gateways, free tier), // kept off this module's imports so preference stores can depend on it without // dragging the session graph in. -export const $modeContext = atom({ connectionCount: 1, profileCount: 1 }) +export const $modeContext = atom({ + connectionCount: 1, + freeTierSignInOpen: false, + profileCount: 1 +}) export function setModeContext(patch: Partial) { $modeContext.set({ ...$modeContext.get(), ...patch }) From ee320cb6c1882a398b82504d863f9ae4c67ad787 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 7 Oct 2026 02:24:29 +0530 Subject: [PATCH 3/5] fix(desktop): a profile switch no longer keeps the previous profile's Sign in (review) Drop the cached free-tier status when the gateway scope (connection + profile) changes; a flap on the same scope keeps it. Feed freeTierSignInOpen into the mode context from store/free-tier, so the statusbar test runs the production link instead of a copy. --- apps/desktop/src/app/contrib/controller.tsx | 3 --- .../shell/hooks/use-status-snapshot.test.ts | 22 +++++++++++++++++++ .../app/shell/hooks/use-status-snapshot.ts | 14 +++++++++++- .../shell/hooks/use-statusbar-items.test.tsx | 10 +++------ apps/desktop/src/store/free-tier.ts | 4 ++++ 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 9e204d62e2..1ac6c52048 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -59,7 +59,6 @@ import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from import { setYoloEnabled } from '@/lib/yolo-session' import { $connectionsRegistry } from '@/store/connection-registry-state' import { watchDeadSessionPrune } from '@/store/dead-session-prune' -import { $freeTierSignInOpen } from '@/store/free-tier' import { $interfaceMode, $showsAdvancedChrome, setModeContext, toggleSimpleMode } from '@/store/interface-mode' import { $fileBrowserOpen, @@ -634,8 +633,6 @@ bindToolPaneCollapse( // Without the statusbar, the rail is the only way to switch profiles or gateways. $profiles.subscribe(profiles => setModeContext({ profileCount: profiles.length })) $connectionsRegistry.subscribe(registry => setModeContext({ connectionCount: registry?.connections.length ?? 0 })) -// Without the statusbar, a signed-out free-tier user would have no standing Sign in. -$freeTierSignInOpen.subscribe(open => setModeContext({ freeTierSignInOpen: open })) // ⌘K door onto the same pane the keybind and statusbar pill flip. registry.register(terminalPaletteToggle) diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index 2284cfdba7..2ab2c5afc7 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NotificationStack } from '@/components/notifications' import { getStatus } from '@/hermes' import { I18nProvider, type Locale, TRANSLATIONS, type Translations } from '@/i18n' +import { $freeTierStatus } from '@/store/free-tier' import { $setupReadyTick, notifySetupReady } from '@/store/live-sync' import { clearNotifications } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' @@ -112,6 +113,27 @@ describe('useStatusSnapshot', () => { } ) + it('drops the free-tier verdict on a profile or source switch, not on a gateway flap', async () => { + const requestGateway = vi.fn().mockRejectedValue(new Error('offline')) as unknown as GatewayRequester + const signedOut = { available: true, enabled: true, has_guest: true, label: '', model: '', notice_pending: false } + + const { rerender } = renderHook( + ({ gatewayState, scope }) => useStatusSnapshot(gatewayState, requestGateway, scope), + { initialProps: { gatewayState: 'open', scope: 'local\0work' } } + ) + + await flushAsync() + $freeTierStatus.set(signedOut) + rerender({ gatewayState: 'connecting', scope: 'local\0work' }) + rerender({ gatewayState: 'open', scope: 'local\0work' }) + await flushAsync() + expect($freeTierStatus.get()).toEqual(signedOut) + + rerender({ gatewayState: 'open', scope: 'local\0home' }) + await flushAsync() + expect($freeTierStatus.get()).toBeNull() + }) + it('pauses status RPCs while visible but unfocused, then catches up on focus', async () => { vi.mocked(document.hasFocus).mockReturnValue(false) const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index a5c62ba888..241c034d82 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { getStatus } from '@/hermes' import { type I18nContextValue, useI18n } from '@/i18n' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' -import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' +import { $freeTierStatus, refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' import { $setupReadyTick } from '@/store/live-sync' import { dismissNotification, notify } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' @@ -14,6 +14,10 @@ import type { StatusResponse } from '@/types/hermes' // visibility listeners refresh immediately on return. const REFRESH_MS = 60_000 +// The scope the cached free-tier verdict was read under. Module-level because +// $freeTierStatus is one app-wide atom, not per hook instance. +let freeTierScope: string | undefined + type GatewayRequester = (method: string, params?: Record) => Promise export function useStatusSnapshot( @@ -48,6 +52,14 @@ export function useStatusSnapshot( setStatusSnapshot(null) publishInferenceStatus(null) + // The free-tier verdict belongs to one profile too. Drop it on a real switch + // only: a flap on the same scope keeps the last answer (refreshFreeTierStatus). + if (freeTierScope !== undefined && freeTierScope !== gatewayScope) { + $freeTierStatus.set(null) + } + + freeTierScope = gatewayScope + // A closed/connecting gateway cannot have an authoritative live-runtime // result. Clear readiness before starting the REST status leg so a hung // getStatus() cannot leave a stale "ready" state visible after disconnect. diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx index d565134d7f..3aa6beede2 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx @@ -2,13 +2,13 @@ import { renderHook } from '@testing-library/react' import type { WritableAtom } from 'nanostores' import { isValidElement, type ReactNode } from 'react' import { MemoryRouter } from 'react-router' -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { group } from '@/components/pane-shell/tree/model' import { $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store' import { createClientSessionState } from '@/lib/chat-runtime' -import { $freeTierSignInOpen, $freeTierStatus } from '@/store/free-tier' -import { INTERFACE_MODES, setInterfaceMode, setModeContext } from '@/store/interface-mode' +import { $freeTierStatus } from '@/store/free-tier' +import { INTERFACE_MODES, setInterfaceMode } from '@/store/interface-mode' import { $onboardingGate } from '@/store/onboarding-gate' import { $connection, @@ -287,8 +287,6 @@ describe('free-tier Sign in in every interface mode', () => { }) const desktopBridge = window.hermesDesktop - // The shell feeds the mode context from the same atom (app/contrib/controller.tsx). - const unwire = $freeTierSignInOpen.subscribe(open => setModeContext({ freeTierSignInOpen: open })) function signInChipVisible(): boolean { const { leftStatusbarItems, statusbarItems } = renderStatusbarItems() @@ -304,8 +302,6 @@ describe('free-tier Sign in in every interface mode', () => { setInterfaceMode('advanced') }) - afterAll(() => unwire()) - it.each(INTERFACE_MODES)('shows the Sign in chip to a signed-out free-tier user in %s mode', mode => { setInterfaceMode(mode) $freeTierStatus.set(freeTier(true)) diff --git a/apps/desktop/src/store/free-tier.ts b/apps/desktop/src/store/free-tier.ts index 7ea8d7820f..2256046a81 100644 --- a/apps/desktop/src/store/free-tier.ts +++ b/apps/desktop/src/store/free-tier.ts @@ -1,6 +1,7 @@ import { atom, computed } from 'nanostores' import { runFreeTierChallenge } from '@/store/free-tier-challenge' +import { setModeContext } from '@/store/interface-mode' import { $onboardingGate, guidedOnboardingActive } from '@/store/onboarding-gate' import { onboardingSurfaceActive } from '@/store/onboarding-presence' import type { FreeTierStatus } from '@/types/hermes' @@ -35,6 +36,9 @@ export const $freeTierSignInOpen = computed( status => Boolean(status?.available) && !guidedOnboardingActive() ) +// Fed here, beside the atom, so every importer of this store gets the same link. +$freeTierSignInOpen.subscribe(open => setModeContext({ freeTierSignInOpen: open })) + function isFreeTierStatus(value: unknown): value is FreeTierStatus { return typeof value === 'object' && value !== null && typeof (value as FreeTierStatus).has_guest === 'boolean' } From 5bff65203e044dee69fdb503b785540473e790d0 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 7 Oct 2026 02:45:36 +0530 Subject: [PATCH 4/5] fix(dashboard): web typecheck checks the app project; ChatSessionList test types its mocks web/tsconfig.json is a solution file (files: [], references), so 'tsc -p . --noEmit' checked nothing and CI's web check passed while the build's 'tsc -b' failed on four errors in ChatSessionList.test.tsx (#134100). The typecheck script now runs 'tsc -b . --noEmit' like hermes-ink, and the test types the act-environment global and the Button mock's extra props. --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 50651b247a..8e214553dd 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,7 @@ "lint:fix": "eslint . --fix", "fix": "npm run lint:fix", "preview": "vite preview", - "typecheck": "tsc -p . --noEmit", + "typecheck": "tsc -b . --noEmit", "test": "vitest run", "check": "npm run typecheck && npm run test && npm run lint" }, From 65bc6727b43c05dff410608c78fa055ec194eee0 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 7 Oct 2026 02:51:10 +0530 Subject: [PATCH 5/5] feat(free-tier): send preview_full_connectors on account creation HERMES_PREVIEW_FULL_CONNECTORS=true|false makes the free-tier mint send {"preview_full_connectors": } in the /api/anonymous/create body, so the account service can record the cohort on the account and gate toolkits from it. Any other value (unset included) keeps the body {}. The variable joins the desktop bundle-env allowlist (release script, banner writer, channel request decoder, BUILDING.md) so a bundle can bake it in. It is self-reported and visible in the packaged JS: the service must treat it as a preference, not proof. --- apps/desktop/BUILDING.md | 4 ++-- .../desktop/electron/updater/channel-protocol.ts | 3 ++- apps/desktop/scripts/bundle-env.mjs | 2 +- hermes_cli/anon_auth.py | 15 ++++++++++++++- scripts/releases/bundle_env.py | 2 +- tests/hermes_cli/anon_portal.py | 2 ++ tests/hermes_cli/test_anon_auth_core.py | 16 ++++++++++++++++ 7 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index ab7140a858..f78f0c07cd 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -187,8 +187,8 @@ For local commit builds, `HERMES_BUNDLE_ENV_JSON` accepts a JSON object whose string values are defaults and whose `null` values are explicit clears. For example, `{"HERMES_HOME":null,"HERMES_DATA_DIR_SUFFIX":"magic-test"}`. Only `HERMES_HOME`, `HERMES_DATA_DIR_SUFFIX`, `HERMES_DESKTOP_USER_DATA_DIR`, -`HERMES_SHARED_AUTH_DIR`, and `HERMES_GUEST_ONBOARDING` -are accepted. Process-control variables such as `NODE_OPTIONS` and `PATH` +`HERMES_SHARED_AUTH_DIR`, `HERMES_GUEST_ONBOARDING`, and +`HERMES_PREVIEW_FULL_CONNECTORS` are accepted. Process-control variables such as `NODE_OPTIONS` and `PATH` are rejected. These settings are not applied to the build runner itself. Commit archive keys still use the SHA, so use a fresh commit for different defaults: an existing artifact is never overwritten with different bytes. diff --git a/apps/desktop/electron/updater/channel-protocol.ts b/apps/desktop/electron/updater/channel-protocol.ts index 9a789d6d2a..01f61b978d 100644 --- a/apps/desktop/electron/updater/channel-protocol.ts +++ b/apps/desktop/electron/updater/channel-protocol.ts @@ -373,7 +373,8 @@ function request(fields: Fields): ChannelRequest { 'HERMES_DATA_DIR_SUFFIX', 'HERMES_DESKTOP_USER_DATA_DIR', 'HERMES_SHARED_AUTH_DIR', - 'HERMES_GUEST_ONBOARDING' + 'HERMES_GUEST_ONBOARDING', + 'HERMES_PREVIEW_FULL_CONNECTORS' ]) for (const key of environment.keys()) { diff --git a/apps/desktop/scripts/bundle-env.mjs b/apps/desktop/scripts/bundle-env.mjs index d2327b28a1..953819519b 100644 --- a/apps/desktop/scripts/bundle-env.mjs +++ b/apps/desktop/scripts/bundle-env.mjs @@ -3,7 +3,7 @@ // Match scripts/releases/bundle_env.py and the channel request decoder. const ALLOWED_KEYS = new Set([ 'HERMES_HOME', 'HERMES_DATA_DIR_SUFFIX', 'HERMES_DESKTOP_USER_DATA_DIR', - 'HERMES_SHARED_AUTH_DIR', 'HERMES_GUEST_ONBOARDING' + 'HERMES_SHARED_AUTH_DIR', 'HERMES_GUEST_ONBOARDING', 'HERMES_PREVIEW_FULL_CONNECTORS' ]) /** Validate a bundle environment object: plain object of identifiers to diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 33cf7b3c20..679819992f 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -50,6 +50,11 @@ ANON_SECRET_ENV = "HERMES_ANON_API_SECRET" # tier did not exist. ``guest_enabled`` is the only reader. Not a user preference: never written to # config.yaml or .env, never shown in setup. Deleted at GA together with this comment. GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING" +# Preview cohort for the free tier's connector set, sent once on account creation so the account +# service can record it on the account. Exactly "true" or "false" is sent as that boolean; anything +# else (unset included) omits the field and the service applies its default. Self-reported and +# baked into desktop bundles in plain text: the service must treat it as a preference, never proof. +PREVIEW_FULL_CONNECTORS_ENV = "HERMES_PREVIEW_FULL_CONNECTORS" GUEST_MINT_TIMEOUT_SECONDS = 5.0 # Copy shared by every surface that names the free tier (R-USR-1): never guest / anonymous / account. FREE_TIER_LABEL = "Nous · free tier" @@ -326,9 +331,17 @@ def _raise_for_anon_status( retryable=code not in ANON_TERMINAL_CODES) +def mint_request_body() -> Dict[str, Any]: + """The ``/api/anonymous/create`` body: ``{"preview_full_connectors": bool}`` when the env var is + exactly ``true`` / ``false``, else ``{}``.""" + raw = (os.environ.get(PREVIEW_FULL_CONNECTORS_ENV) or "").strip() + return {"preview_full_connectors": raw == "true"} if raw in ("true", "false") else {} + + def mint_guest(client: httpx.Client, portal_base_url: str) -> Dict[str, Any]: """``POST /api/anonymous/create`` -> ``{user_id, org_id, token, idle_ttl_days}``. Token shown once.""" - response = client.post(f"{portal_base_url.rstrip('/')}/api/anonymous/create", headers=_anon_headers(), json={}) + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/create", headers=_anon_headers(), json=mint_request_body()) payload = _raise_for_anon_status(response, action="sign-up") token = payload.get("token") if not isinstance(token, str) or not token.startswith("anon_"): diff --git a/scripts/releases/bundle_env.py b/scripts/releases/bundle_env.py index c7c3aa21ec..fbd96edb45 100644 --- a/scripts/releases/bundle_env.py +++ b/scripts/releases/bundle_env.py @@ -7,7 +7,7 @@ import json # Keep this list aligned with the Desktop bundle banner and channel decoder. _ALLOWED = frozenset({ "HERMES_HOME", "HERMES_DATA_DIR_SUFFIX", "HERMES_DESKTOP_USER_DATA_DIR", - "HERMES_SHARED_AUTH_DIR", "HERMES_GUEST_ONBOARDING", + "HERMES_SHARED_AUTH_DIR", "HERMES_GUEST_ONBOARDING", "HERMES_PREVIEW_FULL_CONNECTORS", }) diff --git a/tests/hermes_cli/anon_portal.py b/tests/hermes_cli/anon_portal.py index e6696c8762..93b550c7e6 100644 --- a/tests/hermes_cli/anon_portal.py +++ b/tests/hermes_cli/anon_portal.py @@ -52,6 +52,7 @@ class FakePortal: self.challenge_url = f"{PORTAL}/challenge?code=ticket" self.optional_challenge = False self.token_requests: list[dict] = [] + self.create_requests: list[dict] = [] def creates(self) -> int: return [p for _, p in self.calls].count("/api/anonymous/create") @@ -66,6 +67,7 @@ class FakePortal: if self.gate_closed: return httpx.Response(401, json={"error": "invalid_shared_secret"}) if path == "/api/anonymous/create": + self.create_requests.append(json.loads(request.content or b"{}")) if self.create_response is not None: return self.create_response self.minted += 1 diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index 0f2690bc5a..5b76e84d9d 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -137,6 +137,22 @@ class TestExplicitProvision: assert anon_auth.ensure_portal_identity(explicit=True) is None assert portal.minted == 0 + @pytest.mark.parametrize("raw, body", [ + ("true", {"preview_full_connectors": True}), + ("false", {"preview_full_connectors": False}), + (None, {}), + ("1", {}), + ]) + def test_preview_full_connectors_rides_the_create_call(self, portal, monkeypatch, raw, body): + """The cohort reaches the account service on the one call that creates the account, as a + real boolean; anything but exactly true/false leaves the body empty (the service default).""" + if raw is None: + monkeypatch.delenv(anon_auth.PREVIEW_FULL_CONNECTORS_ENV, raising=False) + else: + monkeypatch.setenv(anon_auth.PREVIEW_FULL_CONNECTORS_ENV, raw) + anon_auth.ensure_portal_identity(explicit=True) + assert portal.create_requests == [body] + class TestResolverIsUnchanged: def test_guest_is_last_resort_and_explicit_key_wins(self, portal, monkeypatch):