Merge remote-tracking branch 'origin/main' into sid/onboarding

This commit is contained in:
alt-glitch
2026-10-07 03:31:03 +05:30
18 changed files with 389 additions and 33 deletions

View File

@@ -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.

View File

@@ -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()) {

View File

@@ -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

View File

@@ -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

View File

@@ -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 = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
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.

View File

@@ -7,6 +7,9 @@ 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 { $freeTierStatus } from '@/store/free-tier'
import { INTERFACE_MODES, setInterfaceMode } 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 }) => <MemoryRouter>{children}</MemoryRouter>
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,59 @@ 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
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')
})
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)
})
})

View File

@@ -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: <Codicon name="account" size="0.75rem" />,
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,

View File

@@ -1,6 +1,8 @@
import { atom } from 'nanostores'
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'
@@ -23,6 +25,20 @@ export type FreeTierRequester = <T = unknown>(method: string, params?: Record<st
*/
export const $freeTierStatus = atom<FreeTierStatus | null>(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()
)
// 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'
}

View File

@@ -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<InterfaceMode, PolicyTable> = {
/** 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<ModeContext>({ connectionCount: 1, profileCount: 1 })
export const $modeContext = atom<ModeContext>({
connectionCount: 1,
freeTierSignInOpen: false,
profileCount: 1
})
export function setModeContext(patch: Partial<ModeContext>) {
$modeContext.set({ ...$modeContext.get(), ...patch })

View File

@@ -50,6 +50,11 @@ ANON_SECRET_ENV = "HERMES_ANON_API_SECRET"
# tier did not exist. 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_"):

View File

@@ -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",
})

View File

@@ -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

View File

@@ -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):

View File

@@ -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"
},

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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);
};
}

View File

@@ -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<ReturnType<typeof setTimeout> | null>(null);
const ptyInputLineRef = useRef("");
const mobileReplacementInputUntilRef = useRef(0);
const [ptyState, setPtyState] =
useState<PtyConnectionState>("connecting");
const [ptyState, setPtyState] = useState<PtyConnectionState>("connecting");
const ptyStateRef = useRef<PtyConnectionState>("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);