Merge remote-tracking branch 'origin/main' into sid/onboarding
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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_"):
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
21
web/src/lib/chat-panel-collapsed.ts
Normal file
21
web/src/lib/chat-panel-collapsed.ts
Normal 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;
|
||||
}
|
||||
70
web/src/lib/terminal-touch-scroll.test.ts
Normal file
70
web/src/lib/terminal-touch-scroll.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
114
web/src/lib/terminal-touch-scroll.ts
Normal file
114
web/src/lib/terminal-touch-scroll.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user