From 00928fc4013cf47a665887b4ac50d49429fb3113 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 06:10:44 +0000 Subject: [PATCH 1/3] feat(cec): the access list owns live state; Forget also disconnects - Fold the Connected banner into "Who can connect to your computer": each technician row carries a status dot above Forget (connected / connecting / not connected) and, while a session is active, a green "Viewing your screen" / "Controlling your screen" chip that pulses gently next to the grant countdown. A session still in "requested" no longer reads as connected (the old banner said "connected" while the approval prompt was still up), and a live session whose grant event hasn't landed yet still gets a row - the signal can't vanish. - Forget is the one kill-switch: it ends any live session with that technician, then revokes and drops the node. The separate Disconnect button (and the banner) are gone. - The chat door next to the name is a real button that says "Chat", keeping the unread badge. - The access list's footer paragraph now lives behind a ? next to the title: tooltip on hover, tap to pin open. - The KVM & Claiming card moves with the moment: bottom-left on the quiet front door; bottom-right while the hand is up, a session is live, or grants stand; back left once everything clears and those sections hide themselves. - The Board row shows the node's raw board field only, no product fallback (paired with the AllMyStuff collector change). - Copy pass: emdashes removed from every user-visible string (UI, toasts, CLI help), and the contact card's explanations shortened with the detail moved into tooltips. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YYdqmManehKSKxwzcfeeU4 --- README.md | 15 +- gui/src-tauri/src/main.rs | 4 +- gui/src/store.svelte.ts | 118 +++++++++++---- gui/src/types.ts | 30 +++- gui/src/ui/AccessList.svelte | 238 ++++++++++++++++++++++-------- gui/src/ui/App.svelte | 22 ++- gui/src/ui/ApproveModal.svelte | 8 +- gui/src/ui/ChatPanel.svelte | 2 +- gui/src/ui/ConnectedBanner.svelte | 59 -------- gui/src/ui/ContactCard.svelte | 30 ++-- gui/src/ui/KvmClaimCard.svelte | 6 +- gui/src/ui/KvmWifiModal.svelte | 2 +- gui/src/ui/SettingsPanel.svelte | 6 +- gui/src/ui/SpecSheet.svelte | 11 +- gui/src/ui/StartScreen.svelte | 8 +- 15 files changed, 351 insertions(+), 208 deletions(-) delete mode 100644 gui/src/ui/ConnectedBanner.svelte diff --git a/README.md b/README.md index 777cea0..2a3d0bb 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,12 @@ is [MyOwnMesh](https://github.com/mrjeeves/MyOwnMesh). See choices — **Approve Once**, **Auto-Approve for 3 hours**, **Auto-Approve Forever** — plus **Deny**. (These map to `ApprovalScope::Once` / `ThreeHours` / `Forever` in `allmystuff-cec-consent`.) -5. **While connected**, a small banner shows "‹Agent› is connected — viewing / - controlling your screen" with a **Disconnect**, and a list of everyone with - standing access (with a live countdown for the 3-hour ones) each with a - **Forget** (revoke) button that bites immediately. +5. **While connected**, the "Who can connect to your computer" list carries the + live state: each technician row shows a status dot (connected / connecting / + not connected) and, while a session is active, a green "Viewing your screen" + (or "Controlling your screen") chip that pulses gently next to the grant's + countdown. Each row has one kill-switch — **Forget** — which disconnects any + live session *and* revokes standing access in the same tap, immediately. 6. **Settings**: a grant-scoped **autostart** policy for surviving a reboot mid-repair — `while_granted` (the default: start with Windows only while an active technician grant is open), `always`, or `off`; the separate @@ -114,8 +116,9 @@ implements them on the AllMyStuff node): Events re-emitted onto the Tauri bus for the UI: `cec://request { tech, agent_name, want_control, session_id, verification_code }` -(drives the modal), `cec://session { session_id, state }` (the banner), and -`cec://grants { grants }` (the access list). +(drives the modal), `cec://session { session_id, state }` (the access list's +live dot + "viewing your screen" chip), and `cec://grants { grants }` (the +access list). The background service is handled by `cec-support-service` directly (not the node), via the `service_*` Tauri commands. diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 05687fe..331f409 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -1211,7 +1211,7 @@ fn run_id() -> ExitCode { fn print_help() { println!( - "CEC Support {ver} — one-tap remote help from Critical Error Computing. + "CEC Support {ver}, one-tap remote help from Critical Error Computing. USAGE: cec-support [COMMAND] @@ -1219,7 +1219,7 @@ USAGE: With no command, opens the CEC Support app (what a customer runs). COMMANDS: - run [--service] Start the client agent headless — join the Silent support + run [--service] Start the client agent headless: join the Silent support mesh and wait. `--service` is the mode the OS service runs. service Install/manage the background service so CEC Support can reconnect after reboots during a repair diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index 84a835d..0ec0a5f 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -51,6 +51,7 @@ import { } from "./tauri"; import { FEATURE_KVM } from "./types"; import type { + AccessRow, ApprovalScope, CecChatMsg, CecKvm, @@ -251,9 +252,13 @@ class CecStore { return this.pending[0] ?? null; } - /** Sessions that haven't ended — the Connected banner list. */ + /** Sessions that haven't ended — connecting or active (a "requested" one is + * still just a prompt, not a connection). Feeds the access list's live dot + * and chip. */ get liveSessions(): LiveSession[] { - return Object.values(this.sessions).filter((s) => s.state !== "ended"); + return Object.values(this.sessions).filter( + (s) => s.state === "connecting" || s.state === "active", + ); } /** Whether this device is live on the support area — the app is up and the @@ -307,6 +312,50 @@ class CecStore { return this.liveSessions.some((s) => canonicalTech(s.tech) === want); } + /** The access list's rows: every standing grant, each joined to that + * technician's live session (if any) — plus a row for a live session whose + * grant hasn't landed yet (a `cec://grants` event can trail the session by a + * beat). One list is the single place "who can connect" and "who is + * connected right now" both show, so the live signal can never render + * somewhere the customer isn't looking. */ + get accessRows(): AccessRow[] { + const live = this.liveSessions; + const rows: AccessRow[] = this.grants.map((g) => { + const key = canonicalTech(g.technician); + return { + key, + technician: g.technician, + agent_name: g.agent_name || "A CEC technician", + grant: g, + live: live.find((s) => canonicalTech(s.tech) === key) ?? null, + }; + }); + for (const s of live) { + // A session event that arrived before its tech id was known can't be + // keyed; skip it rather than render an anonymous row. + if (!s.tech) continue; + const key = canonicalTech(s.tech); + if (rows.some((r) => r.key === key)) continue; + rows.push({ + key, + technician: s.tech, + agent_name: s.agent_name || "A CEC technician", + grant: null, + live: s, + }); + } + return rows; + } + + /** Whether a support engagement is underway in any form: the hand is up, a + * technician is connected (or connecting), or standing grants exist. Drives + * where the KVM & Claiming card sits — the right rail during an engagement + * (the left column is busy with the session), the bottom of the quiet left + * column otherwise. */ + get engaged(): boolean { + return this.askingHelp || this.liveSessions.length > 0 || this.grants.length > 0; + } + /** A friendly display name for a technician peer (canonical id): the live * session's Agent Name if connected, else the standing grant's, else a * generic label. */ @@ -556,8 +605,8 @@ class CecStore { scope === "once" ? "Approved for this session." : scope === "three_hours" - ? "Approved — they can reconnect for the next 3 hours." - : "Approved — they can reconnect until you remove them.", + ? "Approved. They can reconnect for the next 3 hours." + : "Approved. They can reconnect until you remove them.", ); } catch (e) { this.notify(`Couldn't approve: ${errMsg(e)}`); @@ -583,23 +632,27 @@ class CecStore { } } - /** End a live session, leaving any standing grant intact (that's what Forget - * is for). Sends the same End the wire uses for a decline. */ - async disconnect(s: LiveSession): Promise { - try { - await cecDeny(s.tech, s.session_id); - } catch (e) { - this.notify(`Couldn't disconnect: ${errMsg(e)}`); - } - const next = { ...this.sessions }; - delete next[s.session_id]; - this.sessions = next; - this.notify("Disconnected."); - } - - /** Forget a technician entirely — revoke their standing approval and drop - * their node from the mesh. Bites immediately. */ + /** Forget a technician entirely: end any live session they're on right now, + * revoke their standing approval, and drop their node from the mesh. One + * button, one outcome — they're off the screen and can't come back without + * asking again. Bites immediately. */ async forget(tech: string): Promise { + const want = canonicalTech(tech); + const live = this.liveSessions.filter((s) => canonicalTech(s.tech) === want); + // Disconnect first (the same End the wire uses for a decline), so the + // session closes cleanly before the node is dropped from the mesh. + for (const s of live) { + try { + await cecDeny(s.tech, s.session_id); + } catch { + // The revoke + forget below still cut access; a failed End just means + // the transport teardown does the disconnecting. + } + const next = { ...this.sessions }; + delete next[s.session_id]; + this.sessions = next; + } + if (live.length > 0) this.reconcileChatPanel(); try { await cecRevoke(tech); await cecForgetNode(tech); @@ -607,7 +660,11 @@ class CecStore { this.notify(`Couldn't remove: ${errMsg(e)}`); } await this.loadGrants(); - this.notify("Removed. They can't reconnect without asking you again."); + this.notify( + live.length > 0 + ? "Disconnected and removed. They can't reconnect without asking you again." + : "Removed. They can't reconnect without asking you again.", + ); } /** "Ask for help": raise this machine's hand on the support area until a @@ -759,7 +816,7 @@ class CecStore { this.appendChat(key, { id: `demo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, from: "technician", - text: "Thanks — I can see that. Give me one moment.", + text: "Thanks, I can see that. Give me one moment.", ts: Math.floor(Date.now() / 1000), }); }, 1400); @@ -1014,7 +1071,7 @@ class CecStore { } const me = this.localId; if (!me) { - this.notify("Still finding this computer on the mesh — try again in a moment."); + this.notify("Still finding this computer on the mesh. Try again in a moment."); return; } this.busy = true; @@ -1048,7 +1105,7 @@ class CecStore { const peer = (this.snapshot?.peers ?? []).find((p) => this.sameNode(p.node, node)); const site = peer ? this.kvmWebSite(peer) : undefined; if (!peer || !site) { - this.notify("This KVM hasn't published a console yet — can't reboot it."); + this.notify("This KVM hasn't published a console yet, so it can't be rebooted."); return; } this.busy = true; @@ -1257,13 +1314,13 @@ class CecStore { void this.refreshKvms(); } else if (rsp) { this.wifiError = - "Couldn't connect — check the network name and password, then try again."; + "Couldn't connect. Check the network name and password, then try again."; } else { // No reply: the request went out but nothing came back. Most often the // KVM has moved onto the new network and its mesh link is // re-establishing — not a failure we can be sure of. this.wifiError = - "Sent the Wi-Fi details. If the KVM moves onto this network it may drop off for a minute — then reopen Wi-Fi to check."; + "Sent the Wi-Fi details. If the KVM moves onto this network it may drop off for a minute. Reopen Wi-Fi to check."; void this.refreshKvms(); } } finally { @@ -1354,7 +1411,7 @@ class CecStore { promptUnclaim(node: string, label: string): void { this.askConfirm({ title: "Unclaim this KVM?", - body: `${label} will reset — it forgets this computer and offers itself for setup again.`, + body: `${label} will reset. It forgets this computer and offers itself for setup again.`, confirmLabel: "Unclaim", danger: true, onConfirm: () => this.unclaimKvm(node), @@ -1376,7 +1433,7 @@ class CecStore { p.claimable = true; p.kvm = { ...(p.kvm ?? {}), attached_to: undefined }; }); - this.notify("Unclaimed — it's offering itself for setup again."); + this.notify("Unclaimed. It's offering itself for setup again."); return; } this.busy = true; @@ -1449,7 +1506,7 @@ class CecStore { { id: "demo-1", from: "technician", - text: "Hi! I'm connected now — I'll take a look at that printer for you.", + text: "Hi! I'm connected now. I'll take a look at that printer for you.", ts: this.now - 300, }, { @@ -1479,7 +1536,8 @@ class CecStore { this.specs = { hostname: "RECEPTION-01", os: "Windows 11 Pro 24H2", - board: "ASUS PRIME B550-PLUS", + // Raw board_name, exactly as firmware reports it (no vendor prefix). + board: "PRIME B550-PLUS", cpu: { brand: "AMD Ryzen 5 5600G", cores: 6, threads: 12, max_mhz: 4464 }, memory: { total_bytes: 16 * 1024 ** 3, diff --git a/gui/src/types.ts b/gui/src/types.ts index ec39097..e690fe1 100644 --- a/gui/src/types.ts +++ b/gui/src/types.ts @@ -31,14 +31,13 @@ export interface CecStatus { export interface MachineSpecs { hostname: string; os: string; - /** DMI system/board label — "Dell Inc. XPS 15" on a laptop, the - * motherboard model on a custom build. Null/absent when the firmware - * doesn't say (the card hides the row). */ + /** The system's board field, verbatim (Linux `board_name`, Windows + * `Win32_BaseBoard.Product`, macOS `hw.model`) — the node does no parsing + * or formatting on it, and the card shows it as-is. Null/absent when the + * platform doesn't report one (the card hides the row). */ board?: string | null; - /** Just the product / model name — the DMI product field without its - * maker prefix ("XPS 15", not "Dell Inc. XPS 15"). This is what the - * spec card shows: the model identifies the machine, the maker doesn't. - * Null/absent on odd firmware — the card falls back to `board`. */ + /** The product / model name (still on the wire for other consumers; the + * spec card's Board row no longer reads it — Board shows `board`, raw). */ product?: string | null; cpu: { brand: string; @@ -145,6 +144,23 @@ export function grantScope(g: Grant): ApprovalScope { return typeof g.scope === "string" ? g.scope : g.scope.kind; } +/** One row of the "Who can connect to your computer" list: a standing grant, + * its live session when that technician is connected right now, or (rarely) a + * live session that has no grant row yet — merged in the store so the list is + * the single place connection state shows. */ +export interface AccessRow { + /** The technician's canonical device id — the row key. */ + key: string; + /** The id to act on (chat / forget) — as the grant or session carried it. */ + technician: string; + /** Friendly name to show. */ + agent_name: string; + /** The standing approval, or null for a session-only row. */ + grant: Grant | null; + /** The technician's live session right now, or null when not connected. */ + live: LiveSession | null; +} + /** The OS background-service status, from the `cec-support-service` crate. */ export interface ServiceStatus { platform: string; diff --git a/gui/src/ui/AccessList.svelte b/gui/src/ui/AccessList.svelte index f6293d9..5f720c2 100644 --- a/gui/src/ui/AccessList.svelte +++ b/gui/src/ui/AccessList.svelte @@ -1,7 +1,14 @@ -{#if store.grants.length > 0} +{#if store.accessRows.length > 0}
-

Who can connect to your computer

+
+

Who can connect to your computer

+ +
+ {#if helpOpen} +

{HELP_TEXT}

+ {/if}
    - {#each store.grants as g (g.technician)} + {#each store.accessRows as r (r.key)} + {@const dot = dotState(r)}
  • - - +
    + + {r.grant ? scopeLabel[grantScope(r.grant)] : scopeLabel.once} · {access(r)} + {#if r.grant && remaining(r.grant)} + {remaining(r.grant)} + {/if} + {#if dot === "live"} + + {r.live?.want_control ? "Controlling your screen" : "Viewing your screen"} {/if} - - - {scopeLabel[grantScope(g)]} · {access(g)} - {#if remaining(g)}{remaining(g)}{/if} - +
    + + +
  • {/each}
-

Tap a technician's name to chat with them. “Forget” removes their standing access right away — they'll have to ask you again next time.

{/if} @@ -71,9 +120,50 @@ max-width: 30rem; padding: 1.1rem 1.2rem; } - h3 { + .head { + display: flex; + align-items: center; + gap: 0.5rem; margin: 0 0 0.7rem; + } + h3 { + margin: 0; font-size: 1rem; + flex: 1 1 auto; + } + /* The ? — a small round button that carries the old footer text as its + tooltip, and pins it open on tap. */ + .help { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + font-size: 0.85rem; + font-weight: 700; + color: var(--ink-soft); + background: transparent; + border: 1px solid var(--line); + border-radius: var(--r-pill); + cursor: pointer; + } + .help:hover, + .help[aria-expanded="true"] { + color: var(--ink); + border-color: var(--line-strong); + background: var(--surface-2); + } + .help-pop { + margin: 0 0 0.7rem; + padding: 0.55rem 0.7rem; + font-size: 0.82rem; + line-height: 1.45; + color: var(--ink-soft); + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--r-md); } ul { list-style: none; @@ -96,43 +186,33 @@ flex: 1 1 auto; display: flex; flex-direction: column; - gap: 0.25rem; + gap: 0.3rem; min-width: 0; } - /* The name doubles as the chat door: a borderless button that reads as a - name, with a speech-bubble hint and an unread count. */ - .agent { - display: inline-flex; + .who { + display: flex; align-items: center; - gap: 0.4rem; - align-self: flex-start; - max-width: 100%; - padding: 0; - font: inherit; + gap: 0.55rem; + min-width: 0; + } + .agent-name { font-weight: 600; font-size: 0.98rem; color: var(--ink); - background: transparent; - border: none; - cursor: pointer; - text-align: left; - } - .agent:hover { - color: var(--accent); - } - .agent-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .chat-ico { + /* An actual button that says Chat — the speech bubble alone was invisible. */ + .btn.chat { flex: 0 0 auto; + gap: 0.35rem; + padding: 0.25rem 0.6rem; + font-size: 0.8rem; + } + .chat-ico { width: 0.85rem; height: 0.85rem; - color: var(--ink-faint); - } - .agent:hover .chat-ico { - color: var(--accent); } .unread { flex: 0 0 auto; @@ -157,10 +237,50 @@ font-size: 0.85rem; color: var(--ink-soft); } - .foot { - margin: 0.9rem 0 0; - font-size: 0.85rem; - color: var(--ink-faint); - line-height: 1.4; + /* The live chip: green, breathing slowly while they're on your screen. */ + .chip.viewing { + animation: viewing-pulse 2.8s ease-in-out infinite; + } + @keyframes viewing-pulse { + 50% { + opacity: 0.6; + } + } + /* The connection column: status dot sitting above the one kill-switch. */ + .side { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + } + .dot { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; + background: transparent; + border: 1px solid var(--line-strong); + } + .dot.live { + background: var(--ok); + border-color: var(--ok); + box-shadow: 0 0 0 3px var(--ok-soft); + } + .dot.connecting { + background: var(--warn); + border-color: var(--warn); + box-shadow: 0 0 0 3px var(--warn-soft); + animation: connecting-pulse 1.4s ease-in-out infinite; + } + @keyframes connecting-pulse { + 50% { + opacity: 0.45; + } + } + @media (prefers-reduced-motion: reduce) { + .chip.viewing, + .dot.connecting { + animation: none; + } } diff --git a/gui/src/ui/App.svelte b/gui/src/ui/App.svelte index 452b758..f48a86f 100644 --- a/gui/src/ui/App.svelte +++ b/gui/src/ui/App.svelte @@ -6,7 +6,6 @@ import StartScreen from "./StartScreen.svelte"; import ApproveModal from "./ApproveModal.svelte"; import ContactCard from "./ContactCard.svelte"; - import ConnectedBanner from "./ConnectedBanner.svelte"; import AccessList from "./AccessList.svelte"; import KvmClaimCard from "./KvmClaimCard.svelte"; import ConfirmDialog from "./ConfirmDialog.svelte"; @@ -126,19 +125,30 @@ + never opens the number screen, but the access list (which now also + carries "viewing your screen") must never be hidden behind a + navigation step while it's true. + + The KVM & Claiming card moves with the moment: on the quiet front + door (no hand up, nobody connected, nobody with access) it sits at + the bottom of the LEFT column; the instant the hand goes up it steps + over to the bottom of the right rail and stays there until the + engagement fully clears (no session, no grants) — at which point the + access section has hidden itself and the card comes back left. -->
- + {#if !store.engaged} + + {/if}
{/if} diff --git a/gui/src/ui/ApproveModal.svelte b/gui/src/ui/ApproveModal.svelte index 0608bbf..acb7fa8 100644 --- a/gui/src/ui/ApproveModal.svelte +++ b/gui/src/ui/ApproveModal.svelte @@ -14,7 +14,7 @@ const primary = { scope: "three_hours" as ApprovalScope, label: "Approve for 3 hours", - hint: "Best for a repair — stays connected through a restart, no need to re-approve", + hint: "Best for a repair. Stays connected through a restart, no need to re-approve", }; const minor = { scope: "once" as ApprovalScope, @@ -40,8 +40,8 @@

- Only continue if you're expecting {request.agent_name} to connect — - that's the name your technician will have told you to look for. + Only continue if you're expecting {request.agent_name} to connect. + That's the name your technician will have told you to look for.

@@ -71,7 +71,7 @@ disabled={store.busy} onclick={() => void store.deny(request)} > - No thanks — don't connect + No thanks, don't connect
diff --git a/gui/src/ui/ChatPanel.svelte b/gui/src/ui/ChatPanel.svelte index 8afb0a5..11eeb0e 100644 --- a/gui/src/ui/ChatPanel.svelte +++ b/gui/src/ui/ChatPanel.svelte @@ -73,7 +73,7 @@
{name}
-
{connected ? "Connected — you can chat now" : "Not connected right now"}
+
{connected ? "Connected. You can chat now" : "Not connected right now"}
{#if canClose} - - {/each} - -{/if} - - diff --git a/gui/src/ui/ContactCard.svelte b/gui/src/ui/ContactCard.svelte index f6808b5..08b8279 100644 --- a/gui/src/ui/ContactCard.svelte +++ b/gui/src/ui/ContactCard.svelte @@ -18,19 +18,21 @@ tiktok.com/@criticalerrorcomputing -

- Haven't talked to us yet? Call, or catch the live — we'll match you by - the name and Support Number on your computer's card. + +

+ New to CEC? Call us or hop on the live.

-

- CEC Support runs on the AllMyStuff system to connect and - show your screen. Want that kind of access to your own machines, - anywhere? -

-
@@ -90,13 +92,7 @@ flex-direction: column; gap: 0.4rem; } - .promo-text { - margin: 0; - font-size: 0.78rem; - color: var(--ink-soft, var(--ink-faint)); - line-height: 1.45; - } - .promo-text strong { + .promo-link strong { color: var(--ink, inherit); font-weight: 700; } diff --git a/gui/src/ui/KvmClaimCard.svelte b/gui/src/ui/KvmClaimCard.svelte index 8914c13..db51aff 100644 --- a/gui/src/ui/KvmClaimCard.svelte +++ b/gui/src/ui/KvmClaimCard.svelte @@ -96,7 +96,7 @@

- Raise your hand — a CEC technician sees your request and connects. + Raise your hand and a CEC technician sees your request and connects. You approve them before they can see anything.