Get Help (Helpdesk) (part 2)
The second half of the Get Help page: the operator queue and developer console the team answers from — status tabs, drafts, routing and aging rules — plus the boot-time listener, the alerting watcher and the key files.
What it is
This is part 2 of the Get Help (Helpdesk) page. That page covers what Get Help is, how to open it, and the whole life of a question from the moment you ask it to the moment it is resolved. This half carries the machinery behind it: the operator queue and the developer console that the team uses to answer, and the watchers, seams and code files an engineer needs.
Where to find it
Everything here is reached from the same two places as the main page: the Get Help panel for a user, and the Help Desk developer console for the operators answering. On this half the surface is the console and the operator queue inside it — the tabs, filters and tag colours it renders.
How it behaves
Operator queue — views, custom filters & tag colours (dev console)
The operator escalation queue ("Needs your reply") filters like Crisp, all local and cost-free (nothing here calls the cloud or an AI):
- A queue row opens on a click and closes on a middle-click. Click a conversation to open
it. Middle-click it instead — the mouse's scroll-wheel button — and it closes
(resolves) that conversation without opening it, leaving you exactly where you were on
whatever conversation you already had open. It goes through the same middle-click rule as
every other Omniscio row (
onMouseDownwithbutton === 1, neveronAuxClick, which Chromium's autoscroll swallows inside a scrolling list). A row for a conversation that is already resolved does nothing on a middle-click — the Resolved view is history, and there is nothing there to close. - The "All ⌄" view dropdown replaces the old Pending/Resolved tabs. Pick one of eight grouped views — All · Unread (conversations with a new escalation you haven't opened) · Pending (still open, keeps the Awaiting-agent/-customer split) · Unresolved · Resolved · Most Recent · Longest Waiting (most overdue first) · Priority (urgent → normal → low, newest first inside each). Each view sets both the filter and the sort.
- The "Filters" button opens a panel holding the multi-select tag chips (moved here) and your saved custom filters. A badge shows how many filters are active.
- New custom filter… opens the Advanced Filter builder: give it a label, then add one or more criteria (a criterion → a condition → a value). Criteria cover the data Omniscio actually has about a conversation — User email, User name, Conversation state, Assignment, Tag, Creation date, Update date, Waiting time, Registered user, Has device info — so every option really filters something. Multiple criteria must all match (AND). Saved filters are private to your install and can be deleted (with undo).
- Tag colours are optional: when you create a tag you can pick a colour swatch (or leave it on "Auto" for none). The colour is remembered per tag NAME, so a tag shows the same colour everywhere it appears — the queue rows, the filter chips, and the details rail.
The open ticket — header, quoted text and assignment (dev console)
- Always visible at the top: the sender's name, an Email or In-app chat tag showing how the ticket came in, the subject line, and who the ticket is assigned to. The full-screen console shows it in its top strip beside Mark resolved; the small corner widget and a phone show it in the console's own bar, with the assignee as just an avatar.
- On a phone that bar is the only one: the Queue / Help Center tabs step aside while a ticket is open, and the ticket's actions — Start session, Open session and Mark resolved — live in the bar's ⋯ menu instead of in buttons that would push the row onto a second line. The intake tag moves down beside the subject so the sender's name keeps its room. Desktop is unchanged.
- The subject line: an email ticket shows the email's own subject when this computer received the email; every other ticket — and an email ticket opened on a teammate's computer — shows the first line of the opening question.
- Assign from the header: click the assignee to open the same picker as the details rail — search, pick an operator, or choose Unassigned.
- Quoted text folds away (email tickets only): a customer's mail client usually pastes our earlier reply under their answer. The console folds that behind Show quoted text so their new words stand alone; one click shows it again, and nothing is deleted. Messages with nothing quoted, Gmail and Apple Mail forwards, and every in-app chat message show in full, and when the console is not sure where the quote starts it shows everything. An Outlook forward carries no forward marker and looks exactly like a reply, so it folds too.
- Email senders are not "verified": anyone can type any From address, so the details rail shows no "Verified email" row for an email ticket, and the reply box reads "Send your message to … by email".
- Make the reply box as tall as the reply needs (desktop): a small pill sits above the Reply/Note tabs — drag it up and the box grows past the height it would otherwise stop at, drag it back down and the box hands the height back to growing with what you type. It is the same grabber the session and Team Chat composers carry, it changes nothing above the composer (the conversation keeps its own scroll), and on a phone there is no pill: a drag is unusable on touch and the row costs space the shrunken viewport cannot spare.
- Assignment alerts: when a teammate or a routing rule assigns a waiting ticket to you, you get one "A ticket was assigned to you" notification on your computer and your phone, in place of the usual "New help request". Assigning yourself stays silent, restarting the app never repeats an old assignment, and one made while your app was closed is announced once when it next opens.
- Starting a session from the ticket: a Start session control sits on the open ticket, beside Mark resolved — in this console's top strip and in the small widget's Back row. It opens the shared start-session dialog with the ticket's subject and conversation already in the prompt box, and confirming starts a real Claude Code session while changing nothing on the ticket. A ticket that newly ARRIVES also starts one read-only investigation by itself, on the same brief the email-ticket lane uses. Both doors, and what they deliberately never do, are in Starting a session from a Help Desk ticket.
For agents
Developer console — load-on-open + Refresh
The developer-side "Needs your reply" view (HelpdeskIncomingList) is
load-on-open (fetches when the Get Help widget is opened, if the developer
already has console access) with a manual Refresh button and a count badge
on its header icon — and it also refreshes live while the widget is open:
whenever the incoming-question watcher (below) fires, the panel reloads the
queue, and if the updated thread is the one currently open in the chat body it
re-fetches that thread too, so a user's follow-up message appears in the open
conversation in near-real-time (SSE push; within a ~15s poll tick if the stream
is down) without the developer replying or reopening it. There is no always-on
developer-side desktop Firestore listener or badge outside the widget. The escalation email alert is the signal that tells the
developer to look; the widget is the tool they use to reply.
Status tabs — Pending / Awaiting customer / Resolved
The console groups its queue into three tabs, and they are driven by the
statuses filter the desktop passes to the operatorConsole helpdesk.listIncoming
op. listIncomingThreads() asks for the wide set
['waiting_dev', 'answered', 'resolved'], so Pending (waiting on the
developer), Awaiting customer (answered — the developer replied, the ball is
in the user's court), and Resolved all populate. This is deliberately NOT the
same set the ~15s incoming-alert watcher (above) polls: that watcher stays
['waiting_dev']-only, or every already-answered and resolved thread would re-raise
a "new escalation" alert. Before this split the op only ever returned waiting_dev,
so a thread the developer resolved simply vanished and the Awaiting-customer tab
was always empty — the bug this fixes.
From the console the developer can Resolve a thread (or Reopen an
already-resolved one), which routes through the admin-authed helpdesk.resolve /
helpdesk.reopen ops. Each takes only a { threadId }, verifies the admin caller
server-side, and flips the thread's status with a server-stamped updatedAt —
the client never supplies the clock. Resolve → resolved; reopen lands the
thread where the last speaker left it — if the customer spoke last it returns
to waiting_dev (the Pending queue), otherwise answered (Awaiting customer).
Each action also writes a small event marker into the thread, which renders as a
hairline divider in the console only — the user's own chat does not replay
the resolve/reopen events; it shows a single muted "Marked Resolved" note while
the conversation is resolved (the marker still updates their local status). These are
operatorConsole ops, so changes to them take effect only once the Cloud
Function is deployed; until then the desktop buttons degrade gracefully.
Who's online
While the full-screen console is open, an online-admins strip shows which
admins are active right now: each admin's copy of Omniscio sends a heartbeat
through the admin-authed helpdesk.presence op (identity taken from the
verified sign-in, never the request body) and the console polls the roster
every 30 seconds, counting anyone seen in the last 90 seconds as online. A
momentary network blip never flickers the strip empty — it holds the last
known roster, and you always appear as yourself at minimum. The presence data
lives in a collection only the server can touch (client access is denied by
Firestore rules).
Toolbar badge
The toolbar's Get Help button carries an unread badge: for everyone it counts unread developer replies to your own conversations; for an admin with the dev console enabled it also counts waiting customer questions — so an escalation is visible at a glance without opening the widget.
Aging & SLA on escalated threads
While a thread waits on the developer, the Pending tab's Awaiting agent
section ages it so the most overdue escalation never sinks out of sight. The pure
logic lives in src/shared/helpdesk-sla.ts (renderer- and main-safe):
- Oldest-first order —
sortByLongestWaitingre-orders the Awaiting-agent section by longest wait (oldest last-activity first), overriding the queue's default most-recent-first. The Awaiting customer section is left as-is — the ball is in the user's court there. - Age badge — each still-waiting row shows a
Clockpill with a compact age ("5h", "2d"): amber aging once the wait crosses 4h (HELPDESK_SLA_WARN_MS), red overdue past 24h (HELPDESK_SLA_OVERDUE_MS). The badge appears ONLY while the reply is owed (status === 'waiting_dev'); ananswered,resolved, orai_answeredthread shows no badge. - "Waiting since" basis — the age is measured from
lastMessageAt ?? updatedAt ?? createdAt, the same last-activity basis the queue sorts on, so the badge and the order always agree. A future or unreadable timestamp is treated as a zero / least-urgent wait — never a negative age, never an alarm.
Contract invariant: I42 (helpdesk-sla.test.ts). (The paragraph that used to
stand here said this was display-only and that a proactive ping was "intentionally
not built". That is no longer true — see the ticketing section below.)
Standard ticketing — numbers, priorities and response promises
Every ticket carries an operator overlay that the cloud thread knows nothing about:
a local row in helpdesk_ticket_meta (keyed by the cloud thread id, exactly like the
tags and notes beside it), read back into every queue row.
- A ticket NUMBER (
#1042) — minted once, by ONE allocator, from#1000. A batch first sight numbers tickets oldest-first, so an install that meets forty at once numbers them in creation order. It is the ticket's human name: the console row, the ticket header and the email subject all render it through the singleticketReference()formatter. (The console mints it; the email subject and the customer's own view are the one-ticket-one-conversation work's.) - PRIORITY —
low/normal/urgent, defaultnormal, set from the ticket header. It sorts (Priority in the view menu), it filters (a facet beside assignment, and a criterion in the Advanced Filter builder), andurgentis the one chromatic state — an alarm-coloured chip and a left accent bar on the row. - Response promises, per priority —
helpdeskResponseTargetsin Settings holds two numbers for each priority: minutes to the FIRST reply, and minutes to each reply after. The console shows how the ticket stands against the right one — "due in 3h", or "overdue 4h" in the alarm colour — and shows nothing at all when no reply is owed. Which promise applies is decided by whether the ticket has EVER been answered (first_responded_at), never by its current status: a ticket that was answered and came back owes the NEXT promise, and measuring it against the first would call it late about twenty hours early. - ONE notice per overdue episode. A ticket that crosses its promise raises exactly ONE operator inbox card, and nothing while it stays overdue. That IS a background timer (every five minutes) and it IS a proactive ping — the thing the old note above said was not built. Two rules keep it from becoming noise: the sweep's FIRST completed pass stamps every ticket that was already late silently, so shipping the feature cannot fire a notice for the whole pre-existing backlog at once; and an episode that ends (answered, resolved, or the wait reset) both drops the stamp and WITHDRAWS the card, so a later lapse raises a fresh one instead of being eaten by the re-raise cooldown. A ticket whose clock cannot be read is left untouched.
Contract: helpdesk-ticketing-features-contract.md.
Draft with AI — the operator copilot
Inside the dev console thread pane, a Draft with AI button sits just above the reply box. It asks the same doc-grounded AI the customer answer path uses to compose a suggested reply in the agent's own voice, then drops that text straight into the composer for the agent to read, edit, and send. It is a copilot, never an autopilot:
- It drafts; it never sends. The suggestion only fills the reply box (Send lights up as if you'd typed it) — the actual send is still your explicit click. Nothing leaves the app until you send it.
- Dev-console only, admin-gated. The button shows only in the developer console,
and the request carries the same strict
view_helpdesk_consoleadmin check as a developer reply (drafting reads another user's thread and spends on the paid model, so the feature flag alone isn't enough). - Grounded + private. The draft is grounded in the bundled help docs; contact PII
in the conversation is scrubbed before anything is sent to the model. It's metered
under its own
helpdesk-draftcost label (separate from the customerhelpdesk-askspend) and shares the helpdesk daily cost cap. WithAMC_HELPDESK_FAKE=1it returns a canned draft and spends nothing.
Contract invariant: I43 (helpdesk-handlers-dev-auth.test.ts,
helpdesk-answer-service.test.ts, request-thread-draft.test.tsx).
Routing — automatic assignment (Crisp-style rules)
Admins can set ordered routing rules so each incoming escalated conversation is auto-assigned to the right operator the moment it arrives — no manual triage:
- Where: the "Routing" row in the full-screen console sidebar (under Insights) opens the manager dialog: a master "Enable routing" switch, an "Assign on reply" option (an unassigned thread is claimed by whichever operator replies first), and the ordered rule list (move up/down to set priority, click to edit, delete with confirm).
- A rule = a name, optional conditions (contact language, country — derived offline from the reporter's time zone — email, plan tier, signed-in vs anonymous, platform, app version, or a keyword in the question), and who to assign (one or more operators). Conditions combine with And/Or; a condition can list up to 20 values; a rule with no conditions matches everything (put a catch-all last).
- How matching works: rules run top-to-bottom, first match wins. Offline operators are skipped (the rule falls through to the next one) unless the rule's "Assign even when offline" is on; several assignees rotate round-robin so the load spreads evenly.
- Manual control: the details rail's Assignment row — and the assignee button in the ticket header — shows who owns a thread and lets an admin assign, reassign, or unassign by hand; a manual assignment always wins and is never overwritten by rules (a re-escalated thread keeps its assignee).
- The queue shows it: assigned threads carry the assignee's avatar in the incoming list, and the All / Mine / Unassigned filter chips slice the queue.
- Rules are ONE shared cloud config for the whole admin team, evaluated server-side at escalation; any routing fault fails open (the thread just stays unassigned for team pickup — routing can never block an escalation).
Contract invariants: I47–I48 (routing-core.test.ts,
helpdesk-relay-routing.test.ts, operator-console-helpdesk-routing.test.ts,
helpdesk-handlers-dev-auth.test.ts).
Boot-time reply listener (user side) — SSE push + poll fallback
At app startup, after the DB is ready and only when the helpdesk gate is
visible, startHelpdeskReplyListener() (helpdesk-boot.ts) starts the
developer-reply receive path — the app ships NO Firebase admin key for helpdesk.
Since 2026-07-22 this is SSE-first: a long-lived helpdeskStream connection
(ownerKey-scoped, same deterrent-token authorization as the poll) pushes each
developer reply within about a second of it landing; the ~15s ownerKey-scoped
pollReplies poll stays armed as the fallback, stretching to a slow 120s
watchdog while the stream is healthy and snapping back to full cadence the
moment it is not. To save cost, neither transport runs while the install has no
receivable conversation — one that is escalated and either still open or
resolved within the last 72 hours (the reopen window; a resolved conversation
keeps listening through it so a developer's reopen still arrives, then goes
idle — asking to escalate again wakes it). When the developer replies, the
listener:
- Ingests the reply into the local
help_request_messagestable. - Flips the local request status to
answered. - Creates an Inbox notification via
emitPush.
Delivery is idempotent on both transports — re-delivering the same cloud message
does not create duplicate rows or duplicate Inbox items, and each reply fires
only once per session (diffed by messageId; SSE and the poll share the same
dedup). The gate is re-evaluated live whenever the Get Help setting toggles —
enabling it starts the listener immediately, no restart needed
(reconcileHelpdeskReplyListener(), wired into the settings side-effect
registry). Both the stream and the poll timer are torn down on before-quit.
Developer alerting — the incoming-question watcher
Separately from the boot-time reply listener above (which delivers the
developer's reply back to the user), a second watcher runs on the
developer's own copy of Omniscio and alerts them when a user escalates a
question. It is SSE-first too: an admin-authed helpdeskStream connection
(verified Firebase ID token + admin role — the same gate as every operator
console call) pushes each waiting thread as it changes, with a ~15s poll of the
admin-authed operatorConsole relay as the fallback (stretched to a 120s
watchdog while the stream is healthy), so a new escalation or follow-up lands in
about a second:
- Gated on two things at once: the
helpdesk-dev-consolegate must be visible AND the current Omniscio user must be a signed-in admin — checked viaassertCaller('view_helpdesk_console', { strict: true }).strictmode is stricter than the everydayassertCallercheck: it refuses Omniscio's normal "single-user install = operator = allow" shortcut, so this watcher (and the console it feeds) can never run on an ordinary local install just because nobody else is signed in — a real, cryptographically-verified admin sign-in is required. There is no production backdoor. - Reconciled on every sign-in/sign-out AND on a live toggle of the Get Help or developer-console setting, not just at app startup — an admin can sign in or out (or flip either setting) while Omniscio is running and the watcher starts or stops accordingly, no restart needed.
- When a thread enters
waiting_dev(a user escalates), OR a user sends a follow-up message on a thread that is already waiting (every follow-up requeue bumps the cloud doc'supdatedAt, which the watcher's poll diff and the alert row'sthread_updated_atwatermark both track), the watcher:- Writes a local
helpdesk_incoming_alertsrow (a re-delivery of the same still-unread thread state does not re-notify; a previously-cleared thread that re-enters the queue via a user follow-up DOES alert again — and so does a genuinely NEW follow-up message on a still-unread thread). - Raises an in-app Inbox row that opens that thread's console in the main content
area (
HelpdeskInboxDetailPane), the same in-pane shape an alarm or recipe row uses — NOT a deep-link into the floating Get Help panel. The row selects the sharedactiveInboxDetailslice keyed on the ticket's THREAD id, so opening it also highlights the row in the list. (Before 2026-09-30 the row bounced out to the floating panel's "Needs your reply" view instead.) - Sends a mobile push notification, so the developer can be alerted even away from their desk.
- The alert clears automatically once the developer sends a reply from that group.
- Writes a local
- An assignment change re-delivers the thread without counting as a new question: every open console refreshes its queue, and when someone else just assigned the ticket to the signed-in admin, the watcher raises the "A ticket was assigned to you" alert (desktop and phone) in place of a second "New help request". The decision lives in a small tracker whose baseline is the newest assignment this computer has already seen, remembered across restarts: a restart never re-announces an old assignment, and one made while the app was closed is announced once when it next opens.
Test-only seam (AMC_E2E_FAKE_ADMIN)
Because the strict gate genuinely requires a verified admin sign-in, E2E specs
need a way to drive the gated console without a real Firebase login. A single
narrow seam in assertCaller grants a synthetic admin ONLY when every one of
these is true: the capability check is strict, the running binary is
unpackaged (!app.isPackaged — something Electron reports truthfully and
no environment variable can override), the Omniscio instance is isolated
(test-only), and AMC_E2E_FAKE_ADMIN=1 is set. Because a shipped/packaged
Omniscio build always reports isPackaged === true, this seam is physically
impossible to trigger in any binary a real user runs — it exists purely so
automated tests can exercise the strict-gated console end to end.
Looking ahead: the console's authorization currently trusts the desktop client's own verified-claims check (Phase A style — see the entitlements contract). A server-verified read (matching the Phase B key-rotation work already tracked for the rest of Global Auth) remains a known follow-up, folded into that broader effort rather than solved separately here.
AMC_HELPDESK_FAKE=1 test seam
Setting AMC_HELPDESK_FAKE=1 replaces the entire helpdesk-cloud surface with
an in-memory implementation (helpdesk-cloud-fake.ts). Under this flag:
writeThread/appendThreadMessage/listIncomingThreads/getThreadMessages/appendDeveloperReply/setThreadStatusall operate on a module-level in-memory store.startDeveloperReplyListenerfires synchronously when a developer message is appended — no Firestore, no network, no latency.- Alert emails are silenced (no-op).
This lets a single app instance play both roles (user + developer) in tests, exercising the real ingest → Inbox → push chain at zero cost. Tests must never write to real Firestore or send real email under this flag.
Key files
| Concern | File |
|---|---|
| Answer generation | src/main/services/helpdesk/helpdesk-answer-service.ts |
| IPC handlers | src/main/ipc/helpdesk-handlers.ts |
| Firestore cloud transport | src/main/services/helpdesk/helpdesk-cloud.ts |
| In-memory fake cloud | src/main/services/helpdesk/helpdesk-cloud-fake.ts |
| Boot listener + incoming-question watcher | src/main/services/helpdesk/helpdesk-boot.ts |
| Escalation + email alert | src/main/services/helpdesk/helpdesk-escalation.ts |
| Reply ingestion | src/main/services/helpdesk/helpdesk-reply-ingest.ts |
| Incoming-question ingestion | src/main/services/helpdesk/helpdesk-incoming-ingest.ts |
| Incoming alerts DB queries | src/main/db/queries-helpdesk-incoming.ts |
Admin gate (assertCaller) |
src/main/services/global-auth/profile-service.ts |
| Renderer store | src/renderer/src/stores/helpdesk-store.ts |
| Unified-inbox source (incoming alerts) | src/renderer/src/stores/helpdesk-incoming-inbox-items.ts |
| Panel shell (non-modal floating corner widget) | src/renderer/src/features/helpdesk/HelpdeskPanel.tsx |
| Conversation list (your own past conversations) | src/renderer/src/features/helpdesk/HelpdeskConversationList.tsx |
| Incoming/escalation list ("Needs your reply" view, admin only) | src/renderer/src/features/helpdesk/HelpdeskIncomingList.tsx |
| Queue view dropdown (All ⌄ — All/Unread/Pending/…) | src/renderer/src/features/helpdesk/HelpdeskQueueViewMenu.tsx |
| Filters panel (tag chips + saved custom filters) | src/renderer/src/features/helpdesk/HelpdeskFiltersPanel.tsx |
| Advanced Filter builder dialog | src/renderer/src/features/helpdesk/HelpdeskAdvancedFilterDialog.tsx |
| Custom-filter criterion registry + pure evaluator | src/shared/helpdesk-custom-filters.ts |
| Queue view scope/sort helpers | src/renderer/src/features/helpdesk/helpdesk-admin-list.ts |
| Tag colours + custom-filter DB queries | src/main/db/queries-helpdesk-annotations.ts · queries-helpdesk-custom-filters.ts |
| Aging / SLA core (age level + oldest-first sort) | src/shared/helpdesk-sla.ts |
| Response-target math (first vs next, due/overdue) | src/shared/helpdesk-sla.ts (computeTargetState) |
| Ticket overlay vocabulary + the ONE reference formatter | src/shared/helpdesk-ticket-meta.ts |
| Ticket overlay store (number, priority, snooze, merge, stamps) | src/main/db/queries-helpdesk-ticket-meta.ts |
| The queue's overlay join (one helper, both doors, fail-open) | src/main/services/helpdesk/helpdesk-operator-console.ts |
| ONE overdue notice per episode (the five-minute sweep) | src/main/services/helpdesk/helpdesk-ticket-sla-sweep.ts |
| Chat view + composer | src/renderer/src/features/helpdesk/HelpdeskChatView.tsx |
| Escalated-thread chat pane (developer reply) | src/renderer/src/features/helpdesk/HelpdeskDevThreadPane.tsx |
| Message bubbles | src/renderer/src/features/helpdesk/HelpdeskMessageBubble.tsx |
| Ticket header (sender, medium, subject, assignee) | src/renderer/src/features/helpdesk/HelpdeskTicketHeader.tsx |
| Quoted-text fold (view-only splitter) | src/renderer/src/features/helpdesk/helpdesk-quoted-reply.ts |
| Email or in-app chat? (intake channel) | src/shared/helpdesk-intake-channel.ts |
| Assignment alert decision | src/main/services/helpdesk/helpdesk-assignment-notice.ts |
| Email-ticket reply relay | src/main/services/helpdesk/helpdesk-email-reply.ts |
| Feature gates | src/shared/unreleased-features.ts (helpdesk + helpdesk-dev-console) |
Related
Get Help (Helpdesk) is the user-facing half of this feature — asking a question, escalating, and reading the answer. Starting a session from a Help Desk ticket covers the other thing an operator can do with an open ticket: hand it to a Claude Code session, by hand or automatically. The contracts and key files listed above hold the invariants behind what this half describes.
Last verified 2026-09-30