Files
Netcatty/application/state/networkDeviceModeSuggestion.ts
T
Eric Chan a859af6c62 feat(terminal): suggest Network Device Mode via a session-header tip (#2566)
* feat(terminal): suggest Network Device Mode via a session-header tip

When a connected host is classified as a network device (auto-detected
vendor banner/os-release, or a manual distro-icon override) but Network
Device Mode is off, show a non-blocking one-line tip at the top of the
session. The user can enable the mode (persisted to the host) or dismiss
it; we only suggest once per host.

Driven by the host's effective distro so it also previews without real
hardware: set a host's device icon to a network vendor and reconnect.

- domain: shouldSuggestNetworkDeviceMode (exact vendor match, once-per-host)
- application/state: networkDeviceModeSuggestion persistence boundary
- TerminalView: header tip + reserve top offset so it never covers output
- i18n: terminal.networkDevice.tip.* for en/zh-CN/zh-TW/ru

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): gate network device mode tip to plain SSH sessions

Codex review: the suggestion tip used the host's effective distro (incl.
manual icon override), so a Mosh/ET/serial/telnet session with a
network-vendor classification would still offer it and persist a
host-level deviceType:'network'. The Host Details toggle is exposed only
for plain SSH hosts, so this could set a hidden mode that surprises later
SSH reconnects. Add an effectiveProtocol gate to shouldSuggestNetworkDeviceMode
and pass effectiveTerminalProtocol from TerminalView. (#2367)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): make network device tip gate effective and popup/multi-pane safe

Addresses Codex re-review on the tip:
- The effectiveProtocol gate was dead: ctx never carried
  effectiveTerminalProtocol, so it was always undefined (unrestricted).
  Derive it from the host via resolveEffectiveTerminalProtocol instead.
- Suppress the tip when host updates are unavailable (detached popup has no
  onUpdateHost) so Enable can't silently no-op; plumb canUpdateHost through ctx.
- Sync dismiss/enable across panes/windows for the same host via an in-process
  subscription in the suggestion persistence module. (#2367)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): make network device tip once-per-host, ephemeral- and multi-window-safe

Addresses Codex re-review round 3 on the tip:
- Exclude ephemeral deep-link/quick-connect hosts (isSavedVaultHost): they are
  not in the persisted vault array, so Enable would silently no-op even though
  canUpdateHost is true.
- Track first display separately from user interaction: record "shown" silently
  on first display so simply closing the session no longer re-nags on every
  reconnect, while the showing instance keeps the tip until the user acts.
- Propagate resolve/dismiss across separate BrowserWindows (#/session-window)
  via a native storage-event listener that diffs newly handled host ids.
(#2367)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): don't touch localStorage at import; keep network tip clear of speed dial

Addresses Codex re-review round 4:
- P1: networkDeviceModeSuggestion read localStorage at module scope
  (knownHandledIds initializer), which threw ReferenceError under the Node
  test runner and broke `npm test`. Initialize it lazily inside the existing
  browser-only window guard instead.
- P2: when the host info bar is hidden and search is closed the full-width tip
  strip overlapped the compact speed-dial toggle (top-right) and swallowed its
  clicks. Reserve right-side clearance for the toggle via a pure
  resolveNetworkDeviceTipRightInset helper (with unit test).
(#2367)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): drop network tip once the host is classified elsewhere

Addresses Codex re-review round 5: while the tip is latched open, enabling
Network Device Mode from Host Details or a synced pane updates the host to
deviceType:'network', but the eligibility effect's early return kept the tip
(and its now-redundant Enable action) visible. Clear the latch whenever
host.deviceType becomes 'network'. (#2367)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): persist only the deviceType override, not the effective host

Addresses Codex re-review round 5 (P1): the tip's Enable spread the *effective*
session host (group defaults / proxy profile already materialized) into
onUpdateHost, so mergeTerminalHostUpdate froze every inherited field
(credentials, appearance, proxyConfig) as host-level overrides and later
group/profile changes stopped propagating. Send a sparse
{ id, deviceType: 'network' } update instead. (#2367)

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(terminal): move network device tip lifecycle into a state hook; per-host keys

Addresses Codex re-review round 6:
- P1 (architecture): TerminalView no longer imports the persistence API or owns
  the handled-state subscription/eligibility/writes. Extract
  useNetworkDeviceModeSuggestion under application/state; the component consumes
  { visible, enable, dismiss } and keeps only view glue (sparse host update +
  toast), per AGENTS.md's layer boundaries.
- P2 (cross-window clobber): persist one key per host instead of a single shared
  array. localStorage has no atomic read-modify-write, so concurrent appends
  from two windows could drop a marker; independent keys never clobber, and the
  storage event recovers the host id directly from the key.
(#2367)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:11:14 +08:00

74 lines
3.0 KiB
TypeScript

import { STORAGE_KEY_NETWORK_DEVICE_SUGGEST_HANDLED } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
/**
* Persistence boundary for the "enable Network Device Mode" suggestion.
*
* The tip is suggested at most once per host. A host is recorded as handled
* either the first time the tip is *displayed* (so simply closing the session
* does not re-nag on every reconnect) or when the user explicitly
* enables/dismisses it. Displaying it is recorded *silently* (no listener
* notification) so the instance showing the tip keeps it until the user acts,
* while later-mounting instances for the same host are suppressed.
*
* State is stored under one key *per host* rather than a single shared array:
* localStorage has no atomic read-modify-write, so two windows appending to a
* shared array concurrently would drop one another's markers. Independent keys
* never clobber each other.
*/
const keyPrefix = `${STORAGE_KEY_NETWORK_DEVICE_SUGGEST_HANDLED}:`;
const keyForHost = (hostId: string): string => `${keyPrefix}${hostId}`;
export const isNetworkDeviceSuggestionHandled = (hostId: string): boolean =>
localStorageAdapter.readBoolean(keyForHost(hostId)) === true;
type HandledListener = (hostId: string) => void;
const listeners = new Set<HandledListener>();
/**
* Subscribe to handled-state changes for any host. The listener receives the
* host id that changed. Fires for in-process resolves (enable/dismiss) and for
* changes propagated from other renderer windows via the `storage` event.
*/
export const subscribeNetworkDeviceSuggestionHandled = (
listener: HandledListener,
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
const notify = (hostId: string): void => {
for (const listener of listeners) listener(hostId);
};
/**
* Record that the tip was shown for a host without notifying listeners, so the
* instance currently displaying it stays visible while future reconnects and
* later-mounting instances are suppressed.
*/
export const markNetworkDeviceSuggestionShown = (hostId: string): void => {
localStorageAdapter.writeBoolean(keyForHost(hostId), true);
};
/**
* Record an explicit enable/dismiss and notify listeners so any other pane or
* window still showing the tip for this host hides it too.
*/
export const resolveNetworkDeviceSuggestion = (hostId: string): void => {
localStorageAdapter.writeBoolean(keyForHost(hostId), true);
notify(hostId);
};
// Cross-window propagation: the native `storage` event fires in *other*
// same-origin windows (e.g. the detached `#/session-window` peer) when a key
// changes there. Per-host keys let us recover the host id directly.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', (event) => {
if (!event.key || !event.key.startsWith(keyPrefix)) return;
notify(event.key.slice(keyPrefix.length));
});
}