---
title: Get Help (Helpdesk) (part 2)
---

# Get Help (Helpdesk) (part 2)

## What it is

This is part 2 of the [Get Help (Helpdesk)](helpdesk.md) 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](helpdesk.md): 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 (`onMouseDown` with `button === 1`, never `onAuxClick`, 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
  seven 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). 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 Back row, with the assignee as just an avatar.
- **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.

## 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** — `sortByLongestWaiting` re-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 `Clock` pill 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'`); an `answered`, `resolved`, or
  `ai_answered` thread 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.

It is display-only: it gates no spend, blocks no action, and adds no background timer.
A proactive "you have an overdue thread" ping is intentionally **not** built — the
sort + badge surface urgency the moment the operator looks at the queue. Contract
invariant: **I42** (`helpdesk-sla.test.ts`).

### 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_console` admin 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-draft` cost label (separate from the customer `helpdesk-ask`
  spend) and shares the helpdesk daily cost cap. With `AMC_HELPDESK_FAKE=1` it 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:

1. Ingests the reply into the local `help_request_messages` table.
2. Flips the local request status to `answered`.
3. 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-console` gate must be
  visible AND the current Omniscio user must be a **signed-in admin** — checked via
  `assertCaller('view_helpdesk_console', { strict: true })`. `strict` mode is
  stricter than the everyday `assertCaller` check: 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's `updatedAt`, which the watcher's poll diff and
  the alert row's `thread_updated_at` watermark both track), the watcher:
  1. Writes a local `helpdesk_incoming_alerts` row (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).
  2. Raises an in-app Inbox row that deep-links straight to that thread in the
     "Needs your reply" view.
  3. Sends a mobile push notification, so the developer can be alerted even
     away from their desk.
  4. The alert clears automatically once the developer sends a reply from
     that group.
- **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` / `setThreadStatus` all operate on a module-level
  in-memory store.
- `startDeveloperReplyListener` fires 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`                                                         |
| 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)](helpdesk.md) is the user-facing half of this feature — asking a question, escalating, and reading the answer. The contracts and key files listed above hold the invariants behind what this half describes.
