实习日志 / English / UI Refactoring / The staff workbench: from a mirror of the database to a workspace that highlights one action
The staff workbench: from a mirror of the database to a workspace that highlights one action
中文 | English
Once the claimant side was in order, the staff workbench came next, on 3 September. The day's discussion ended in a long spec: the first half reviews the current state and sets the target structure, the second half is the data contract agreed with the backend. This piece follows the same order.
Starting point: everything appears at once
The staff app at the time ran on one employee/index.html. Even after the scripts and styles were split out, index.html was still about 20KB and app.js about 1,942 lines, with DOM queries, string templates, global state variables and API calls mixed together, and styles.css stacking an early visual scheme, responsive patches and later token overrides.
The structural consequences were concrete. Adding a claim state meant changing queue rendering, detail rendering, tabs and button logic at the same time. Queue, details, actions, conversation and assistant could not be tested as separate components. Old CSS was still in the cascade, so "the visual changed and the old style covered it again" happened often. And there was no guarantee that the URL, the current claim, the current tab and the server revision agreed.
The consequences in use were more direct. Opening the workbench, staff saw at the same time the left navigation, queue filters, stat cards, the claim list, hover previews, detail tabs, evidence, history, the conversation, the human review form, customer replies, Agent templates and a floating assistant. Each was useful, but all of them fought for the top visual level, and staff could not answer:
- What needs handling now?
- Which claim should I open first?
- Once open, what is the most important blocker?
- What is the one main action right now?
- What state will that action change?
So I wrote the goal of the refactor as one sentence: the workbench must change from "an admin page showing all available information and actions at once" to "a workspace that helps staff decide which claim to handle first, understand the current work, complete one clear action and leave a traceable record". New colours or more cards cannot solve that.
Three layers, in the order of the work
The principle was to remove no information and layer it by the order staff work in:
- Work summary: claim identity, lifecycle, current responsible party, confirmed facts, missing information, risk signals, the current blocker and the main next step.
- Actionable content: the staff actions the current state allows, each stating its purpose, the input it needs, what it changes, whether it sends anything externally, whether it needs confirmation, and how to continue on failure or timeout.
- Reference material: evidence, all fields, claim context, history, the full conversation, retrieval sources, third-party records and the audit trail.
Only one main action is highlighted at a time. Other actions can sit beside it but may not compete with the same visual weight. The old version laid out evidence review, signal judgement, outcome recording and customer update as a "three-step guided flow". That was easy to present in a demo, but in real use it forced staff through a fixed process. The new version lets the backend projection decide what should be done now, and the front end only presents it clearly.
The queue's problem was that concepts at different levels sat side by side. The old filters included All claims, Urgent requests, Human requests, Awaiting evidence, Professional review and Ready to progress, and cards also carried assignment, tags and AI confidence. I split them into three layers: first group by current responsibility or pending work, then order by priority within a group, and finally use tags, risk signals and filters to help find things.
Two rules were set on purpose here.
Fraud is not a queue of its own. A claim can carry several tags. If one unconfirmed judgement by the Agent became a default queue called Fraud, staff would have a conclusion in mind before even opening the claim. Risk signals may appear only as signals or tags in the work summary, and staff judge for themselves after seeing the evidence and sources.
Unfinished claims are real claims. A report the user left midway can have low priority, but it must stay in the queue, not collapsed or hidden, showing where it stopped, what has been saved, what is missing, the last meaningful interaction, the next follow-up time and when it will be cleaned up automatically.
Key information also cannot depend on hover. Touch screens have no hover, keyboard users should not be forced to explore invisible content, and hover previews cover other claims. Every queue entry must by default give enough to decide whether to open it, with more available through an explicit button, an expand control, or the claim tab.
Two questions about ordering
Two questions came up in the discussion, and my answers later became rules.
Can staff change priority by hand? That is, can they switch a claim to urgent from a drop-down without recording any reason? My answer was that there is no such arbitrary edit. Staff should record a new fact, a new emergency, a risk judgement, a change in service deadline, and the backend recalculates priority from it. If a manual override is ever really needed, it must be a formal action with a reason, an operator, a validity period and an audit record, never an ordinary field edit.
Can the front end sort on its own? Staff can of course click "by last update" or "by priority" to view the list; that is fine. What is not allowed is the front end inventing business rules: putting anything with a fraud tag on top, treating more missing fields as more urgent, sinking all unfinished claims to the bottom, or equating the latest update with the highest priority. The backend returns an explainable priority level, rank and reasons, and the front end responds to the staff member's choice using only these projected fields.
Opening is not accepting
Accepting a claim must be an explicit, visible action, and it must update the shared claim state so the claimant can see that "someone is handling it". Opening a claim only changes the viewing context; it is not accepting it, and it must have no business side effects.
We checked the lifecycle of human assistance at the time:
queued -> accepted -> in_progress -> resolved
After the claimant asks for help it is queued; once staff accept, it becomes accepted and the claimant's next-step status becomes human_support_in_progress; after staff send the first message visible to the claimant it becomes in_progress; once resolved, the claimant sees a safe update or a new next step. The backend tests for this chain passed.
But the check also found two gaps. The claimant app had full status cards for queued and accepted, and for in_progress only one line: "A Northwind staff member is now assisting you." The bigger problem was that there was no real-time sync at all: no WebSocket, no SSE, no polling on the claimant side. When staff accepted or sent a message, a claimant sitting on the old page saw nothing until they refreshed or came back. A well-designed status card cannot cover this gap, so I made it a required dependency of the workbench refactor: accepting, messaging and resolving must all produce events that can be caught up on, the claimant side receives updates by event cursor and revision, and after a disconnect it can fill in missed events.
Who can accept, and who can change what
Accepting uses a mixed model. If staff pick up claims entirely on their own, some claims are never picked up; if the system assigns everything automatically, claims can land on someone who has no time right now. So:
- the backend forms queues and a recommended order by work type, priority, deadline and each staff member's scope;
- staff explicitly click
Accept claimor "handle next claim"; - revision comparison and a short reservation prevent two people from accepting at once;
- the person who accepts becomes the primary owner;
- after a long time without meaningful activity, there is first a reminder, then an explicit event returns the claim to the queue;
- silent reassignment is not allowed while there is an unsubmitted draft, an external action running, or active collaboration;
- transfers and collaboration record the request, acceptance, rejection, permission changes and times;
- a normal transfer is accepted by the current primary owner; only staff with dispatch permission can force a transfer, when the owner does not respond or a deadline requires it, and must give a reason;
- approved collaborators can change ordinary fields and reply to the claimant; transfer, closing the claim and high-impact decisions stay with the primary owner.
Permissions were set item by item:
- claims not accepted are read-only by default;
- facts the claimant has confirmed cannot be silently overwritten; changes leave a correction record, and major corrections are flagged clearly;
- high-impact content such as cover, liability and fraud cannot be changed in a generic field editor; it goes through dedicated review or decision actions;
- there is no arbitrary workflow state drop-down; state transitions come only from legitimate business actions;
- staff can start controlled closing actions such as duplicate, withdrawn or no response, but cannot delete claims directly; actual deletion of unfinished claims is done by the retention policy and scheduled jobs.
Every real write, external message, third-party call, acceptance and resolution, collaboration and transfer, closure and high-impact decision is re-checked by the runtime for identity, permission, current revision, required consent and action preconditions, and the result is recorded. A model's suggestion, a staff Agent's draft or a button in the front end does not by itself grant authority.
Several claim tabs
Staff can open several claims at once. Within one staff member's client, a claim can have only one tab, and opening it again switches to the existing tab; otherwise the same browser would hold two drafts, two revisions and conflicting pending actions. Different staff can open the same claim at the same time, but anyone who is not the owner is read-only by default and can see the current owner, the last activity time, and an entry to ask to collaborate or take over.
Each tab keeps its own position, expanded sections, message draft and pending actions, all restored after a browser refresh. When the server revision changes, a floating notice says so clearly; staff drafts are never silently overwritten.
Many open tabs use memory, but browsers have no reliable cross-browser memory-pressure API, and guessing a limit from an unstable metric is not dependable. My approach was to separate "logically open" from "fully loaded in memory". All open tabs write only lightweight state to local storage; the current tab and a few neighbours keep their full data; tabs not visited for a while release large evidence, message and retrieval data and reload by the saved claim ID and revision when you switch back. The tab list is always there, so staff never lose their working context because of memory management.
Conversations: one external conversation per claim
The staff app manages two kinds of conversation: the business conversation with the claimant, bound to a claim, and conversations between staff and the workbench Agent. Both are managed under Claim conversations, clearly labelled by type. Opening a business conversation from the list jumps to the conversation area of that claim's tab, so staff do not leave the claim's main flow while working on it.
A claim may leave several past conversations because the claimant paused, resumed or started a new one, but at any moment there is only one active conversation with the claimant. When several staff work together they all join that conversation, distinguished by sender. Discussion between staff uses internal notes or staff action records, never a "second conversation" that could be confused with the customer conversation.
The staff Agent: drafts only, never executes
When we checked the current state, the staff Agent did not really exist yet. The existing Agent API was built for the claimant side, with the motor claim prompt and a restricted claim context; it did not accept multi-claim context, and the model's tool calls were rejected. Persistence held only claim-bound conversation records, with no sessions, turns or suggestion records of the staff Agent's own.
The direction we set: the staff Agent is a workbench-level global capability. It does not reset with the claim tab, does not need @agent inside the customer conversation, and staff just ask in natural language. The most important rule is that context must be explicit: the Agent may not implicitly treat the current page or the currently open claim as its context. Each time, staff choose the scope from four options:
- no claim attached;
- one claim attached;
- several claims attached;
- query all data they have permission to access.
The data model has three records:
| Record | Contents |
|---|---|
StaffAgentSession | Session ID, staff ID, title, status, explicit scope, created and updated times, last activity, version |
StaffAgentTurn | The staff question, the Agent's reply, answer type, claim IDs and revisions used, sources, limits, model and prompt provenance |
StaffAgentSuggestion | Suggestion ID, its turn, target claim, kind of suggestion, action code, draft content, revision it is based on, sources, required permission and confirmation, status, expiry |
Every answer must say whether it is an explanation, a handling suggestion, a draft reply to the customer, an action awaiting confirmation or an action already taken. The Agent API only produces explanations, suggestions and drafts; it offers no universal execution entry that could bypass the formal business APIs. Turning a draft into a real action takes seven steps:
- staff click
Use draft; - the draft fills the matching customer message, staff action, third-party request or decision component;
- the interface shows the target claim, the recipient, the data to be shared, the sources relied on and the expected state change;
- staff edit and confirm;
- the runtime re-checks permission, ownership, revision, consent and action preconditions;
- the action runs through the corresponding formal API;
- the suggestion is marked used, abandoned, expired or invalid, and linked to the actual execution result.
Staff can therefore use everything the Agent can do, and a piece of text the model wrote is never mistaken for a business action that has already happened.
Material unfolds in three levels
Evidence, retrieval results, policies, history and third-party data already had some APIs, none of them mature. They could not be piled into one giant form, so they unfold in three levels:
- Level one, queue judgement: each claim shows only what is needed to decide whether to open it: reference number, owner and acceptance status, priority, incident summary, the most important missing item, the most important risk signal, unread messages, services being waited on, and last activity time.
- Level two, the claim workspace: by default shows the claim header, current summary,
Needs attentionand the one main action. Other material is grouped by business meaning into Fields, Conversation, Evidence, Reference checks, External services and Activity. These names describe information responsibilities and do not lock the final column layout. - Level three, a single full record: opening one piece of evidence, a signal, a retrieval result or an external request shows its full source, time, confidence, limits, history and related actions in a detail panel, drawer or its own sub-route.
The default treatment of each kind of content was also set. Risk signals go into Needs attention, with level, icon, heavier type and "needs review" meaning. Fields are grouped by branch and business area, highlighting missing, conflicting, unconfirmed and recently changed ones by default. Policies, history and retrieval results may be grouped under Reference checks, but each item keeps its specific source type and is never merged into a vague lump of "AI material". When a module's dependency is unavailable, that module says what is unavailable, why, and what staff can do now; one local failure must not bring down the whole page. Visually there are no cards inside cards; grouping, spacing, small headings, lists and detail panels come first.
Staff login and routes
Staff login still relied mainly on synthetic tokens for development and testing, with no staff accounts, sessions, login and logout or identity lifecycle equivalent to the claimant side. The direction: staff accounts are created in advance by the organisation or the control plane, with no self-registration; staff and claimant identities are stored separately, with separate sessions, roles and revocation boundaries; the browser uses a secure session mechanism and can later connect to enterprise OIDC through an identity adapter.
Routes were formalised too:
| Route | Meaning |
|---|---|
/staff/login | Staff login |
/workbench | Default claim queue and workspace |
/workbench/claims/:claimId/:section? | A given claim, optionally jumping to a section |
/workbench/conversations | All conversations the user can manage |
/workbench/conversations/:sessionId | One conversation |
/workbench/agent/sessions/:sessionId | One staff Agent session |
Entering a claim route checks identity, access, ownership or collaboration, and the current revision. No permission, claim not found, claim archived and session expired each have their own explicit state instead of sharing one generic error page. Browser back and forward can only restore a legitimate viewing context, never an expired write. The control plane is a separate product and permission entry, not embedded in the workbench.
The front end may not decide backend fields
When we got to data structures, I set a procedural rule for the refactor: both the workbench and its backend may change, and the refactor may propose adding or removing fields based on how staff actually work, with no need to copy the existing contract; but backend fields may not be decided by the front-end refactorer alone without discussion. So every field in the document is marked as one of three: confirmed, current state verified from code, or a proposal still to be discussed.
The check showed many problems in the existing list projection. Priority reused the human assistance priority HandoffPriority. The claimant-facing "who is responsible for the next step" and internal staff assignment were mixed in one field. The full list of outstanding evidence was put straight into the list. Missing information, risk summaries, unread messages, acceptance status and service deadlines had no stable typed summaries. The detail projection returned many full arrays at once, and some of them were weakly typed generic dictionaries.
By this point the product directions, routes, migration approach, tabs, mobile, tokens, tests, three-level unfolding, Agent scope, staff accounts, the acceptance model and real-time sync, were agreed and no longer went back and forth one by one. The remaining field contract got one concentrated confirmation: the refactorer provides readable candidate JSON and field descriptions, and once confirmed, the code, docs and tests are changed directly to the contract and the backend owner is notified.
The contract confirmed in one pass
The contract begins with seven principles:
- Claim state remains the only authoritative source of the claim's current facts.
- Lifecycle, work items, ownership and follow-ups are formal business records, not computed by the front end.
- Queue, priority, work summary and allowed actions are role-scoped projections generated by the backend from authoritative records.
- A claim can have several work items, but the queue picks one current highest-priority piece of work as the main entry.
- A risk signal is always only a signal to look at, never a conclusion about fraud, liability, cover or declining the claim.
- Lists return only the summary needed to decide whether to open; full records load on demand.
- Every projection carries a revision, and every write is re-checked against that revision.
Then a set of enums:
| Enum | Values |
|---|---|
Lifecycle lifecycle_state | draft_active, waiting_customer, waiting_external, staff_support, professional_review, ready_to_create, creating, created, withdrawn, expired, purged_or_anonymised |
Ownership ownership.state | unassigned, reserved, assigned |
| Current staff member's access | read_only, primary, coworker, dispatcher (a projection only, not persisted) |
| Work priority | routine, standard, high, urgent, immediate |
| Attention for missing information | required_now (blocks the current main action), needed_next (affects the action right after), follow_up (needed later) |
| Attention level of a risk signal | notice, review_required, urgent_review, immediate_action |
| Status of a risk signal | open, under_review, dismissed, resolved |
| Action availability | available, confirmation_required, blocked |
| Confirmation level | none, explicit, high_impact |
A few trade-offs are worth explaining. The new lifecycle_state and the old workflow_state are two dimensions, and migration must not overwrite one with the other. routine marks work that is still valid but comes later in service order right now, such as an unfinished claim whose follow-up time has not arrived; it does not mean the claim is unimportant. Risk signals use "attention level" rather than "severity", because it expresses how soon staff need to look, unrelated to the probability that the signal is true; even a signal under_review may never be shown as confirmed fraud.
A queue entry changed from flat fields to groups:
{
"claim_id": "clm_01J4Y7Q2AW",
"display_reference": "NW-1042",
"revision": 42,
"claimant": { "customer_id": "cus_1042", "display_name": "…" },
"incident": {
"family": "motor",
"summary": "Rear-end collision on Queen Street; vehicle remains drivable."
},
"lifecycle_state": "staff_support",
"workflow_state": "professional_review",
"ownership": {
"state": "unassigned",
"current_staff_access": "read_only",
"pending_cowork_requests": 0,
"pending_transfer_requests": 0
},
"priority_projection": {
"level": "high",
"rank": 120,
"reasons": [
{
"code": "CLAIMANT_WAITING_FOR_STAFF",
"summary": "The claimant has requested staff assistance.",
"source_refs": ["hnd_01J4Y7XG2C"]
}
],
"due_at": "2026-09-03T03:30:00Z",
"is_overdue": false
},
"work_summary": {
"queue_key": "claimant_support",
"current_work_item": { "type": "human_support", "status": "open", "owner_role": "claims_professional" },
"primary_action_code": "human.accept_handoff",
"missing_information": [
{
"kind": "field",
"code": "other_party.registration",
"attention": "needed_next",
"blocked_action": "external.request_assessment",
"responsible_party": "claimant"
}
],
"risk_signals": [
{
"code": "HISTORY_INCONSISTENCY_REVIEW",
"attention_level": "review_required",
"status": "open",
"summary": "A prior record may refer to a different insured item."
}
],
"unread_claimant_messages": 1
}
}
display_reference is a number staff can scan, rather than a database primary key in disguise. Claimant information in the queue keeps only the minimum needed for identification. The incident summary is generated by the backend with its source; the front end does not stitch fields together itself. reasons tells staff why the claim sits where it does, without exposing the model's reasoning. Missing information can be a field, a piece of evidence, a confirmation or an external result, not only form fields.
The most important part of the details is allowed_actions. The model gets no power to configure buttons, and the front end cannot call APIs freely by string. This data is an action projection the runtime computes from the current staff identity, claim revision, ownership, lifecycle, work items, consent state and registry version:
{
"action_code": "conversation.send_claimant_message",
"target_ref": "ses_01J4Y7RPN8",
"label": "Reply to claimant",
"purpose": "Continue the accepted claimant support conversation.",
"availability": "available",
"confirmation": { "level": "explicit", "message": "This message will be visible to the claimant." },
"expected_effects": ["message.append", "handoff.mark_in_progress"],
"based_on_revision": 42
}
action_code must come from the action registry. expected_effects is only for previewing to staff and asserting in tests, never a substitute for the real result. based_on_revision lets stale actions be rejected before they run. A blocked action is returned only when explaining the current blocker is genuinely useful, so the page is not loaded with every unavailable button.
By default the details return only a summary of each area (how many fields, how many need attention, how many conversations, how many unread), and full records load on demand through paged sub-resources:
| Resource | Given by default in details | API when expanded |
|---|---|---|
| Claim core, ownership, priority, work summary | Full | GET /api/v1/workbench/claims/{claim_id} |
| Dynamic fields | Count and number needing attention | …/fields |
| Sessions / messages | Count and unread summary | …/sessions, …/sessions/{session_id}/messages |
| Evidence | Summary | …/evidence |
| Policy, history, retrieval results | Reference summary | …/retrievals |
| Risk and review signals | Summary of the highest attention level | …/signals |
| Work items | Current work item | …/work-items |
| External services | Waiting summary | …/external-requests |
| Audit and activity | Last activity time | …/events |
Every sub-resource is paged and carries availability and limit metadata. If one sub-resource is unavailable, only its area is affected, and the whole detail view does not fail.
Last came "who is responsible". The old ResponsibleParty had a value northwind, but Northwind is the organisation that buys this product and owns the business process, not a specific party responsible for a piece of work. The new values are claimant, claims_professional, external_party and system. The Agent cannot be a responsible party; the model cannot own business responsibility. A particular staff member's ownership lives only in ownership and the work items; system means only work that has been authorised and is being carried out by the runtime or a configured system.
Existing northwind values in historical data cannot be replaced globally as a string; they migrate by what they meant at the time. Human assistance, professional review and staff updates become claims_professional; authorised claim creation or internal system steps carried out by the runtime become system; waiting for a third party's reply becomes external_party; waiting for the claimant to provide or confirm becomes claimant. Where the meaning cannot be determined from the source record, automatic migration is blocked and the case goes to human checking rather than guessing.
Replacing the old workbench gradually
Migration is gradual. First split the functional boundaries and tokens, then polish module by module. During migration the old employee/index.html is deprecated but not deleted yet. Once every real function has moved and passed acceptance, the old formal implementation is deleted and the old page survives only in an archive outside the repository. Tests that depend on the old static HTML can be rewritten or deleted, but a rewrite must verify new, user-observable behaviour, never copy the old implementation just to pass. In the end there may not be two parallel formal workbenches.
Directories are split by responsibility:
employee/src/
app/router/ app/workbench-state/
components/
GlobalNavigation/ Queue/ ClaimTabs/ ClaimSummary/
ClaimActions/ ClaimReference/ ClaimConversation/
StaffAgent/ Status/
api/ styles/ main.jsx
Visually the workbench keeps using the token file shared with the claimant app, sharing most of the colours, type roles, weights, radii, shadows and state semantics. The staff side can be denser, with a minimum size verified at 14px first, but key states, the main action and risk content cannot shrink into illegibility for the sake of density. Density itself is tokenised too: navigation width, queue row height, cell padding, section spacing, compact control height and tab height, never tweaked pixel by pixel in components.
The result that day: the workbench's React and Vite app, a separate staff login boundary, formal routes, the claim queue, paging, filters, claim tabs, details, progressively unfolding resource areas, Claim conversations and the staff Agent were all done. The staff Agent supports explicitly choosing zero to many claims as context, persists sessions, shows sources and limits, and outputs drafts. The old index.html no longer hosts the formal workbench and points to the new componentised one. Front-end and backend tests, builds, lint, format checks and type checks all passed, including the full backend suite of 1,297 tests.
What remained was real-time sync across the two sides. The claimant-side event API existed, but in this round the workbench only had revision-based polling notices; browser-level real-time acceptance had to wait until the event chain was stable.
What the two sides share and what they do not
The workbench and the claimant app share the product character, tokens, button levels, state semantics, error model and accessibility principles. They also share the boundary "the Agent only suggests, the runtime executes" and the state protection of "predictable navigation, explicit way back". But the workbench does not copy the claimant layout. The claimant side centres on the Agent conversation and low cognitive load, with the input as the main entry; the workbench centres on ordering tasks, judging claims, traceable actions and handling several claims in parallel, with the claim queue and the current main action as the main entry.
"Share the character, not the density", which we had said in the discussions two days earlier, only took concrete shape here.