v9
UCS Operator - changelog
All notable changes to this project will be documented in this file.
[9.49.1] - 2026-10-19
⚙️ Miscellaneous Tasks
- Moved to new build system
[9.49.0] - 2026-10-15
🚀 Features
- (device) Trace ring device resolution, sink changes and playback
- Finding out why a ring played on the wrong device took screenshots of the console from the kiosk, because nothing in the traces said which device an element was actually set to.
Spans now carry service.instance.id and session.id, the same values the classic log sends as ident and s_ident, so a session found in the log by username can be followed into the traces.
device.resolve records the resolved device id, and on a miss the labels that were available, so a device renamed by the OS shows up next to the saved label. setSinkId runs in a device.sink span with the requested and previous sink. Choosing a ring device in the popup emits device.select with the previous and new device. The ring device.play span records the selected and saved device, the element's sink as it was before the reapply, the ring tone, the media state and whether the user has interacted with the page.
The ring effect reads those values from the store when the ring starts rather than selecting them, so it does not re-run only because something it traces changed.
🐛 Bug Fixes
- Fix lint issues
- Stop api calls from receiving the nullable ids their guards imply
- Declaring the api arguments showed call sites that hand a possibly null
or undefined id to a method that needs one. Most were only guarded by
the query's
enabledflag, which the type checker cannot see through, so they now use skipToken, the way role-query already does: the rating template queries in ViewRatingForm and LinkTabName, and the paired device query in ContinueButton.
- Declaring the api arguments showed call sites that hand a possibly null
or undefined id to a method that needs one. Most were only guarded by
the query's
Small behaviour changes:
- ContinueButton skips the device query when the paired line has no device_id, where it used to call mobility.getDevice with undefined.
- Identify's unpair checks for a line id rather than a line, so a line without an id no longer calls mobility.userLogout with undefined; it falls through to the plain unpair like a missing line does.
- TemplateDialog's insert button does nothing until a template is picked. The button is never disabled, and pressing it early sent a null template id to crm.tasks.template.use.
- sendMessage takes a string or an array of strings and tells them apart with Array.isArray instead of comparing constructors.
dtmf casts the keyboard event payload to KeypadSymbol. The event emitter types every payload as unknown, and the soft phone keypad is the only dispatcher of phoneKeyboard.press.
- Parse the api results callers read directly
- With every api result typed as
unknown, eleven call sites still read fields off a raw response. They now parse it with zod first.
- With every api result typed as
Each schema checks only the fields its caller reads, and uses z.looseObject so the rest of the response passes through untouched. The chat session and the extension settings are stored or handed on whole, and a stripping z.object would drop the fields their consumers read.
- chat: the session id from presence.getChatSessionWithUser
- identify: the ip string from frontend.myip
- sms: the rows-and-total pair from sms.list, via listWithTotalParser, matching dict_and_total in the server's psql.py
- admin structure: extension settings keyed by extension_id
- admin users: id, username and displayname, with superuser and public left unchecked since the page only tests them for truthiness and the server sends a lookup value or False for superuser
- email templates: the new template's id
- email: the reply body payload, the recipients email.again reports and the emails email.associate links
- outbound: the served lead's id, number and name
- task queue: the allocated task's id
- task templates: the rendered content and its TemplateType, in both RichTextarea and the lexical TemplateDialog
A response that does not match now fails the call's promise with a zod error where it used to fail later on a missing field. The admin users page waits for a successful query instead of only the end of loading, so a failed load stays on the loading text rather than crashing on undefined data.
- (uphone) Reapply the ring sink before every ring
- On an Electron kiosk with two USB sound cards, the incoming-call ring played on the default output (PCM2912A, also the call output) instead of the selected ring device (PCM2902C) until the user re-selected the ring device in the softphone popup.
The ring element reported the right sinkId the whole time and setSinkId never failed, so nothing was logged. A fresh Audio element with the same setSinkId calls, in any order and with overlapping calls, played on the right device. Only the long-lived element whose sink was set during app startup kept playing on the default output. Setting the sink to '' and back to the device made it reopen the output on the right device, and that works while the element is paused.
So the ring now goes through '' and back to the ring device right before each play. The unattended bridge ring uses the same device and the same long-lived element pattern, so it does the same. A failed reapply only logs a warning and still plays. The ring is skipped if the call stopped ringing during the reapply.
- (uphone) Log every ring and audio device failure
- The ring-on-the-wrong-device problem left nothing in the log, and several other failures in the same path only reached the browser console, which nobody sees on a kiosk. Failures there now go to the classic log at fail level, with enough detail to tell them apart.
Ring and bridge ring play errors were warnings without the sink or the media state; they are now failures that include both, as is a failed sink reapply. A ring tone or auto-answer sound that fails to load used to stay silent until the next play; the audio elements now log it from their error event. "Ring Play successfully started" is no longer logged when the ring was skipped.
The WebRTC remote audio logged a failed play only to the console, and its setSinkId chains logged success even after a failure because the success handler ran after the catch. Device sync logged a missing permission only to the console and left a failed enumerateDevices unhandled.
A saved device that is not available any more falls back to the default device; that is now a warning listing the available labels, since an OS renaming a device after a reboot looks exactly like this. It stays a debug line when no device was chosen yet.
🚜 Refactor
- (lexical) Drop the dead isLink state from the floating toolbar
- setFloatingToolbarPosition no longer receives isLink, and nothing else in FullFloatingToolbar read the prop, so it only lingered as a useCallback dependency. Removing it there leaves the parent's isLink state without a reader, and updatePopup with its selectionchange and update listeners existed only to set that state, so they go too.
The dependency did re-run the positioning effect when isLink flipped, but isLink only changes on a selection change, which the editor's own SELECTION_CHANGE and update listeners already reposition on.
⚙️ Miscellaneous Tasks
-
(typing) Annotate a batch of implicitly typed parameters
- Clears 14 typecheck errors in files that had only one or two, with no runtime change:
-
Semantic UI change and click handlers get their real data types (InputOnChangeData, MenuItemProps, React.MouseEvent) or
unknownfor an unused event. -
VolumeInput takes the device name from its own prop instead of the name Semantic echoes back in the change data. It is the same value, and the prop keeps the DeviceType that InputOnChangeData widens to any.
-
withTitleGuarded takes HelmetGuarded's title type and a ReactNode.
-
actionReactions.saga types the action it re-dispatches as a redux Action.
-
FloatingFullToolbarPlugin stops passing isLink to setFloatingToolbarPosition, which takes three arguments and never read a fourth. isLink stays in the useCallback deps so the toolbar still repositions when it changes; oxlint flags that dep as unnecessary.
-
(typing) Annotate another batch of implicit types and globals
- Clears 25 typecheck errors with no runtime change:
-
Semantic UI handlers in CRMPersons, FormRow and FinalSemanticCheckbox get
unknownfor the unused event and the real data type (SearchProps, CheckboxProps). -
Untyped locals and parameters get their types: the StopWatch timeout id, the NumbersField accumulator, the ChatBox message content, the SpreadSheetEdit loader's api and agent id, and the rest arguments of the temp tab removal actions.
-
webpack_public_path and window.Operator are declared, so public-path.ts and the non-legacy startup in index.tsx type check.
-
ThemeChange imports React like every other component, since the tsconfig uses the classic JSX transform.
-
The flexbox test iterates a const tuple so the values keep their literal types.
StopWatch's timeout id now starts as undefined instead of null; both are no-ops for clearTimeout.
- (typing) Declare the call, queue status, tts and chat api methods
- Callers already invoke these methods; they were missing from ApiInterfaceTypes, so every call site failed with "not assignable to keyof ApiInterfaceTypes". Clears 29 typecheck errors.
Argument types are what the callers send. Results are unknown where
callers ignore them, zod-parse them or hand them to untyped action
creators. The exceptions are tts.variants.get_audio, whose base64 string
goes straight into an audio data URL, and
presence.getChatSessionWithUser, whose caller reads the session id.
queues.agentAUX's middle argument is a queue id, as changeAgentStatus labels it.
call.dtmf stays undeclared: its tone comes from the untyped event
emitter as unknown, so declaring it would only move the error. The
sms methods stay undeclared too, since their callers index into the raw
results and the response shapes are not written down anywhere.
- (typing) Declare the queue membership, template and email api methods
- Clears 34 typecheck errors from calls to methods missing from ApiInterfaceTypes.
Arguments follow what the callers send. The queue membership edits take their ids plus an open set of settings, because updateAgentQueue builds one settings object for every queue type and spreads it in. The template edit takes its id plus open fields, since the editor's form values allow a null group_id that the template parser does not.
Results stay unknown where callers ignore them or zod-parse them. They
are typed where the caller reads them directly: the new template's id,
the reply body's payload, the recipients email.again returns, and the
emails email.associate links.
config.users.list and the email filter add and edit stay undeclared. The users page reads fields of each user without a parser, and editFilter is sometimes handed an Immutable map, so neither has a shape to declare without guessing.
- (typing) Declare the remaining api methods callers invoke
- Declares every method still called without an ApiInterfaceTypes entry: agent profiles and rating templates, mobility and device identification, provisioning, roles, users, lookups, extension settings, outbound queue admin, email filters, queues.serve, the AI recording driver, CRM contacts and tasks, call.dtmf, email.add and the sms methods.
Every result is unknown, including the handful the two previous
commits typed by hand. Responses are meant to be parsed with zod where
they are used, so a declared result shape would only be a second,
unchecked copy of the parser. The callers that read results raw are
moved onto parsers two commits later.
The sms signatures follow ucs/api/sms.py in the server repo: sms.list takes limit, offset, begin, end, queue_id, number and largest_id.
The email filter add and edit take unknown, since editFilter is
sometimes handed an Immutable map rather than a plain filter object.
queues.${method} in changeAgentStatus is computed and cannot match a
declaration.
Typing the arguments surfaces call sites that pass nullable ids or untyped values; the next commit deals with them.
-
Satisfy oxlint in the files the typing work touched
- The lint:changed job lints whole files, so the errors already present in files this branch edits failed the pipeline.
-
RichTextarea returns its ternary without a wrapping fragment and renders the template dropdown with a ternary. handleTemplateSelect returns early for a string task id, which also types the crm.tasks.template.use call: the dropdown only renders for a numeric id, so the guard never fires.
-
The admin users list drops its empty props interface and keys rows by user id.
-
ViewRatingForm's rating query returns the call instead of wrapping it in an async function without an await.
-
Chat stops destructuring the unused sender id, the identify saga uses an optional catch binding, and cleanCurrentIdentifier, which never yields, becomes a plain function; takeEvery calls either.
-
Identify's unpair and TemplateManager's add handler list the stable store, api and dispatch they use as dependencies.
[9.48.0] - 2026-10-14
🚀 Features
- (ui) Let a tooltip wait before opening
- Tooltip gains a
delayprop, in milliseconds, set on Base UI's trigger and defaulting to 200. ElementRoot's TooltipProvider no longer sets a delay of its own: with a zero group delay Base UI ignores every trigger'sdelay. The provider now only supplies the shared instant phase -- once one tooltip is open, the next one hovered opens at once, until 400 ms after the last one closes.
- Tooltip gains a
Tooltips used to open instantly. They now wait 200 ms, so a hint no longer flashes up while the pointer merely crosses its trigger.
- (email) Add a download button to each attachment row
- Clicking an attachment name opens it in a new tab, and saving it took an Option/Alt-click, which few users know about and which does nothing on a touch screen or in the desktop app. Each attachment in a received email now gets its own download icon next to the name. It shows when the row is hovered or focused, and always in touch density, where there is no hover.
The icon links to the attachment with ?download=true and the download
attribute, the same as "Download individually", so the server makes the
browser save the file. The compose form's existing download icon opened the
file in a new tab just like the name did; it now uses the same component and
really downloads.
The tooltip also mentions the modifier-click shortcut (Option on macOS, Alt elsewhere), so people who use the button learn it. The hint is left out in the Electron app, where modifier-click is browser behaviour it lacks. It waits 600 ms, Base UI's own default, before opening: the icon sits right next to the name, and without a delay its tooltip popped up whenever the pointer crossed the row on its way elsewhere.
- (ui) Add a text input
- Base UI's Input in the shared control frame, so a plain text field sits level with the select and combobox triggers and follows the density and the theme like they do. The email composer's subject is its first user.
- (email) Build the composer header from lib/ui controls
- The header was Semantic throughout: dropdowns for From, To, Cc and Bcc and a Form input for Subject, told apart by icons. It now uses lib/ui -- the select for From, the combobox with chips for the address fields and the new input for Subject -- in a grid with a text label per row, the way mail clients lay it out.
Base UI has no counterpart to Semantic's allowAdditions, so a valid address that is not in the lookup is offered as an "Add ..." item first in the list. Two of its multiple-mode defaults are overridden: it clears the query after every pick and closes the list after a pick made while filtering, which would undo keeping a partial query to pick several matches in a row. Clearing stays with address-input's rules.
Suggestions now show the lookup record's name beside the address and match on either, so a contact can be found by name.
- (ui) Let a tooltip set its open delay
- Passes
delaythrough to Base UI's trigger. Left unset, the tooltip keeps Base UI's default.
- Passes
- (ui) Add status hues from the design tokens to the palette
- Status colours (ok, warning, critical, info) were hard-coded hues in each component that used them, so they could drift apart and none matched the design system. The palette now carries --status-ok-hue, --status-warning-hue, --status-critical-hue and --status-info-hue, taken as the oklch hues of the designer's ok, warn, danger and info tokens for each theme (light 164, 73, 30, 265; dark 161, 79, 28, 266). They are hues only: components keep building colours from the palette's theme-aware --l-N/--c-N levels, so text contrast stays where it was tuned.
- (ui) Add a slider
- A Slider (root and control), SliderTrack and SliderThumb on Base UI, one part per file like lib/ui/select. The track takes its background from the caller and the thumb its colour, so a slider can preview what its values mean; the warning level editor paints its track with the colours values will get. The thumb grows with the touch density.
- (ui) Add a threshold meter for percentages with warning levels
- ThresholdMeter shows a 0-100 % value as a bar with a mark at its target. It takes the warning and critical levels and whether the value is bad when high or low, and colours itself: green while fine, amber past the warning, red past the critical level, neutral without levels or without a value. The mark sits at the critical level, or at the warning when that is the only one.
The status rule is meterStatusOf, a pure function with tests. It takes plain numbers, so lib/ui does not depend on any widget's settings format.
It is built on Base UI's Meter and reads as role="meter" with a label and value for screen readers. The bar grows with the comfortable and touch densities. A parent can recolour the track and mark through --threshold-meter-track and --threshold-meter-mark.
- (widget) Add a warning level editor
- ColorLevelJS -- three clickable colour boxes with two bare number fields between them -- is not understood by users. Its purpose, colouring a value as a warning when it gets too high or too low, is nowhere on screen: the fields have no labels, colours change only by clicking a box, warning on a low value means recolouring all three boxes, and times have to be typed in seconds.
WarningThresholds asks for the same thing in the user's terms. The levels are two dots, yellow and red, on a slider whose track is painted the way values will be coloured, with example values under it. Clicking a dot's value opens a field to type it (times as m:ss) or remove the level; a removed level comes back with a button. The arrows button flips between "too high" and "too low" and keeps the dots in place. A caller can fix the direction and give default levels, which the editor uses when a level is added; WarningSummary renders the levels as one line for a collapsed view.
Behaviour worth knowing:
- The scale is log1p for counts and durations, where the meaningful levels are small, and linear for percentages, which sit near 100. Values snap to whole numbers, and durations to 1 s below 15 s, then 5 s, 15 s, 1 min and 5 min as they grow. Counts and durations end at the round value above twice the highest saved level, derived on every render. A drag works on a draft of the levels that is saved on release, so the scale does not move under the pointer and the settings change once per drag. The values of two close dots are shown side by side between them so they cannot overlap.
- Arrow keys move a focused dot by one value step (ten with Shift) and save at once; the slider's own 1/1000 position steps would round back to the same value on the log scale. A dot cannot step onto or past the other.
- A typed value commits on Enter or blur; an empty or unreadable one reverts, so clearing the field to retype does not remove the level. A value that would cross the other level pushes it one step along, or drops it when there is no room (below 0, above 100 %).
- The track is solid bands -- fine, warning, critical -- split at the dots, the same discrete states the widgets colour values with; a smooth blend would preview shading no widget draws. JS only decides the state (severityOf, statusBandsOf) and where things sit on the track; the colours are CSS in warning-levels.module.scss, from data-status and the palette's status hues at its oklch levels: 700 for text, which stays above WCAG AA 4.5:1 in both themes, and 600 for the track and dots. StatusText renders a value in its state's colour.
The editor stores the existing colorLevels/colors pair through warning-rule.ts, so calculateColorJS and all saved settings work unchanged and no migration is needed. A combination the old picker allowed but no rule describes is read as the closest rule and the editor says so; nothing is rewritten until the user edits.
color-level/index.tsx exports currentColors, which the tests compare the encoding against.
- (widget) Add queue statistics table widget
- Supervisors build queue overviews as a grid of "Queue status (tile)" widgets labelled with "Note" widgets, configuring every tile with the same queues as its row and the same statistic as its column, where one wrong tile is easy to miss. QueueStatistics takes the queues and statistics once and draws them as a table, following the live queues design.
- (widget) Filter queue statistics rows by queue type
- Supervisors want a table of "every inbound queue" that keeps up with queues added in UCS later. Picking queues by hand freezes the list, and leaving it empty mixed in outbound, email and task queues.
Add a "Queue types" setting. It narrows the rows to queues of the chosen types, both when the queue list is empty and when queues are picked. To keep the two settings from contradicting each other, the queue picker only offers queues of the chosen types, and changing the types drops picked queues that no longer match. Queues without a type are hidden while any type is chosen.
Existing widgets parse the missing field as an empty list and keep showing every type.
- (widget) Make queue type and queue selection exclusive
- Combining the two filters made the settings hard to read: a picked queue of another type silently vanished, and the picker's list changed under the user as types were toggled. They are alternatives instead.
With any queue type chosen the table shows every queue of those types and the queue list is ignored. Choosing a type clears the picked queues and disables the queue picker, whose description says why; clearing the types enables it again. The picker lists every queue once more.
- (widget) Group the queue picker by queue type
- With many queues the flat, alphabetical picker mixed inbound, outbound, email and task queues, so finding the ones of one kind meant reading every name.
The queue picker in the queue statistics settings now lists queues under their type, in the order inbound, outbound, email, task, and by name within a type. Queues of a type the app does not know end up under "Other"; empty groups are left out. The grouping is computed by a tested pure function, and IdMultiSelect takes optional groups so the team picker stays flat.
🐛 Bug Fixes
- (docs) Don't add format: md for Docusaurus
- (ui) Give the tooltip trigger its own class prop
- The previous commit made the tooltip's className the class of the span around the trigger and defaulted it to tw:inline-flex, but the popup still read the same prop as its max-width override. With a default always set, the max-w-xs fallback and the wide variant were never reached, so every tooltip lost its width limit and picked up the trigger's layout class. AttachmentThumbnail, which passes className to size the popup, had its max-width applied to the trigger as well.
The trigger span now takes triggerClassName, and className goes back to overriding only the popup's max-width. The phone button, the recommended shade and the bridge participant row move to the new prop.
- (email) Stop chosen addresses from being deselected or duplicated
- Base UI's multiple mode toggles: picking an item that is already selected removes it. The list still offered chosen contacts, and offered "Add x" for an address already in the field, so picking either took the chip out instead of keeping it.
Chosen addresses are now left out of the list, and every path that adds addresses merges ignoring case, so EVA@insoft.cz no longer sits beside eva@insoft.cz. Enter on an address that is already there clears the input instead of doing nothing.
- (email) Translate the composer header labels as a mail client does
- The labels shared their message ids with every other "From" and "To" in the app, so Czech rendered "To" as "Do", the end of a date range. They now carry an "email header" context and read Od, Komu, Kopie, Skrytá kopie and Předmět, the names Czech mail clients use.
Cc and Bcc get a help icon whose tooltip says who sees those recipients, since the abbreviations mean little to someone who has not met them.
- (topmenu) Wrap the dial to the next row when it no longer fits
- Below 1400px the dial's caller label (and the idle number input) took
width: 100%; max-width: 350px. When the bar decides where to break the line, that percentage cannot resolve, so the dial slot is measured with the label at its content width (~220px). Once placed, the percentage resolves against the slot and the label grows to 350px, so the slot keeps its place on the first row and its hangup, hold, transfer, mute and timer items run off the right edge instead of wrapping. On a 1366px screen with the status strip on row 1 they overflowed by ~30px; it took a window below ~1000px for the dial to wrap.
- Below 1400px the dial's caller label (and the idle number input) took
Growing the label with flex: 1 1 auto instead gives it the same up-to-350px
width when there is room, but only into free space, so the slot's measured
and placed widths agree and it wraps as soon as its content does not fit.
Verified in a standalone page with Semantic's menu rules and the legacy slot rules at 1366, 1200, 1000, 900 and 600px: no overflow at any width.
🚜 Refactor
- (lexical) Debounce the code action menu with the local debounce
- The code action menu plugin was the only importer of lodash-es, which is not even a direct dependency; it came in transitively and had no typings (TS7016). modules/debounce is a port of lodash's debounce with the same leading, trailing and maxWait semantics, so use it instead.
One difference matters here. lodash turns max-wait on whenever the
options object has a maxWait key, even an undefined one, and then caps it
at wait; the local port only does so for a number. useDebounce always
passed { maxWait }, so both of its callers (the mouse-move handler and
the copy button's reset) were capped at ms. Passing maxWait ?? ms
keeps that.
- (store) Drop the unused top-level suspended list from ucs state
- ucs.suspended held the queue IDs the logged-in agent is suspended in, but nothing ever read it. Consumers use the per-queue suspended flag in agentQueues instead, and the EVENT_AGENTS_QUEUES_SUSPEND/RESUME handlers never updated the list, so it went stale between full agent updates.
Its only effect was as an implicit "this is me" marker: handleAgents and the single-agent update only copied agentStatus, agentReason and statusSource into state when result.suspended was present, which handleAgent set for the logged-in agent when the payload carried queues. Both callers now check agentId === thisUserId && result.queues explicitly, which keeps the behaviour identical, including skipping the status copy for a self-update that arrives without queues.
- (ui) Move the remaining hover popups to the lib/ui tooltip
- The first pass left four plain hover popups on the Semantic wrapper: the path type icon in the report download path filter, the participant list of a dispatching room bridge with three or more people, the recommended shade in widget settings, and the queue name on the supervisor phone button.
Tooltip gains an optional className for the span that wraps the trigger, replacing the default inline-flex. The participant row needs it to keep the flex row with min-w-0 so the name still truncates, and the phone button and the recommended shade need a block-level trigger. An inline span sits on the text baseline, which lifted the shade circle out of the column's vertical centre.
The phone button rendered its popup disabled when the interaction had no queue; it now renders the tooltip only when there is a queue. The popup was aligned to the right of a trigger as wide as the button, so the arrow pointing at the trigger's centre was pushed to the tooltip's left edge; it is centred now.
The recommended shade used to be narrowed by a .recommendedColor > div rule that no longer matches once the tooltip span sits between the column and the picker. CirclePicker's own width prop replaces it.
- (tabs) Drop the unused tabName prop from LinkGeneratingTab
- No caller ever passed tabName, so every temp tab it opened was named null and resolved its title from the link. The prop's type still advertised a StoreDescriptor, one of the ways a tab name can read an arbitrary store path through StoreValue. Removing it leaves one fewer route into that mechanism ahead of removing it altogether.
- (tabs) Stop accepting a StoreDescriptor as a temp tab name
- No addTempTab caller passes a StoreDescriptor, and the last component that advertised one, LinkGeneratingTab, no longer does. Narrowing the type keeps new callers from reaching StoreValue, which resolves the descriptor's path against the whole store and so couples tab titles to the state shape.
- (tabs) Remove store-path temp tab names and StoreDescriptor
- A temp tab name, and a reference's content, could be a string[] or a StoreDescriptor, which TempTabNameStateSelector and StoreValue resolved by walking an arbitrary path through the whole store. No caller produces either: addTempTab no longer accepts them, temp tabs are never persisted, and every reference helper is called with a plain string.
Drop both variants from the temp tab and reference types and delete TempTabNameStateSelector, StoreValue and StoreDescriptor, so no tab title depends on the shape of the store.
- (email) Explain cc and bcc on hovering the label instead of an icon
- The help icon added a second target beside a label that is short enough to carry the tooltip itself. Hovering Kopie or Skrytá kopie now opens the explanation after 200ms, so passing the pointer over the header does not flash it.
- (ui) Drop the duplicate delay declaration from the tooltip props
- TooltipProps declared delay twice: picked from Base UI's trigger props and again as a plain optional number. Keep the plain one, which is what the component defaults to 200 and passes on to the trigger.
- (email) Rely on the tooltip default delay for cc and bcc
- The tooltip now opens after 200 ms unless told otherwise, so passing the same value on the cc and bcc labels only repeated the default.
📚 Documentation
- (agents) Add scripts for documentation screenshots
- The user manual on doc.insoft.cz needs screenshots of the app in Czech and in English, taken from the shared test backend, whose data is full of real addresses, colleagues' names and test junk. The site is public, so none of that may show.
shot-helpers.mjs opens the app with the UI language rewritten in the whoami response, so the account keeps its locale, and replaces real data with one set of examples: every e-mail address mapped the same way on every run, the logged-in user's name, given texts, table columns by header, and e-mail bodies, which render in iframes. email-shots.mjs takes the E-mails section's shots with it; every step only reads data.
🧪 Testing
-
(ui) Add stories for the input
- Input was the one control in lib/ui without stories, so its states could only be seen inside the email composer. The stories cover the plain, empty and disabled states, all three densities side by side, the input inside a Field with a description and with an error, and a controlled use through onValueChange.
-
Pin how tab names, Note content and colorPath resolve state paths
- Three user settings read the ucs state by path: %path% variables in a tab name, %path% variables in Note widget content, and a widget's colorPath. Each parses and resolves the path its own way, and these tests record today's behaviour, quirks included, before the paths are moved behind a shared resolver and the state off Immutable:
-
tab names and Note read an integer segment as a number key and a quoted one as a string key; colorPath does neither, so queues.47.x never resolves there
-
tab names substitute a whole USER_ID segment; colorPath substitutes %SP_USERNAME% and %IDENTIFIER% textually, so a value with a dot splits into several segments; Note substitutes nothing
-
Note keeps the raw %variable% for any falsy value, including 0, and turns only the first %% into %; tab names drop 0, null and missing values and unescape every %%
-
colorPath uses any non-null value, prefixing it with #
The state is built by test-utils/ucs-state from plain objects, so moving off Immutable means changing that helper rather than every fixture.
⚙️ Miscellaneous Tasks
- Describe documentnation in claude.md
- (typing) Type ucs interface re-exports and transit-immutable-js
- The ucsInterfaces barrel re-exported types with plain
export {}, which isolatedModules rejects (TS1205) on every line. Switch them toexport typeand drop the two re-exports of ./eventUserUpdate and ./EventPeerChange, files that no longer exist and that nothing imports through the barrel.
- The ucsInterfaces barrel re-exported types with plain
transit-immutable-js ships no typings, so each of its eight importers
reported TS7016. Declare the toJSON/fromJSON pair the app uses.
fromJSON stays any, as it effectively was, so callers are unchanged.
With toJSON/fromJSON typed, TabImport now surfaces that JSON.parse returns unknown under ts-reset; the provisioning settings are a JSON string wrapping the transit string, so annotate it as such.
-
(typing) Fix the UcsMirrorState import path in the cdr dropdown editor
- The path pointed two directories too shallow, so TypeScript could not resolve the module (TS2307) and the editor's options prop lost its type. The import is used only as a type and is erased from the build, which is why the broken path never failed at runtime.
-
(typing) Declare the API fields and arguments callers already send
- Several ApiInterfaceType entries described less than the app sends to the server, so every such call failed to type check (TS2353, TS2554):
-
email.edit: state, tags, persons and agent_id
-
crm.persons.edit: every field but id is optional, plus note and data
-
crm.interactions.edit: metadata
-
agent.profile.queue.edit: suspended is optional, plus priority and data
-
config.extensions.edit: settings
-
agent.rating: the trailing totalPointsPercentage
-
provisioning.set: everything after data is optional, since callers pass three, five or seven arguments
The same goes for a few local signatures: setEmail's reply flag is
optional and boolean, spawnNotification accepts the service-worker
actions field that lib.dom no longer lists in NotificationOptions, and
the userAdd open/close reducers take no payload.
Only declarations change; the calls themselves are untouched.
- (typing) Declare redux-mock-store
- It ships no typings, so the three test files built on a mock store reported TS7016. Declare configureStore and the mock store it returns.
- (typing) Annotate implicitly typed parameters
- Give a type to parameters that noImplicitAny flagged (TS7006, TS7031) where the type is unambiguous: unused event arguments become unknown, DOM handlers get the React event they receive, and values passed straight on to a typed function borrow its parameter type.
Annotations only. Three more candidates (LookupTabs, VolumeInput, ContactBook) were left as they are, because typing their first argument exposes a mismatch in the second one that needs a real fix.
- Remove unused localizations
[9.47.0] - 2026-10-08
🚀 Features
- (action-history) Show referenced objects as cards in history notes
- A history note that links an email or a task, such as the E#/T# note the Link task dialog writes, rendered as a small coloured chip inside a speech bubble and looked like any other comment.
Every reference in a note now gets its own card below it: an icon tinted by kind, the object's real title and a meta line (subject, direction, sender and date for an email; queue and state for a task; the name for a person), with the reference on the right. Clicking a card opens the object. A note made only of references shows just the cards; a note with context keeps its text above them. Comment and data notes that carry a reference get a link icon in the feed.
With embedEmailPreview on, every referenced email is previewed rather than only the first one the old regex found.
- (task,email) List linked objects in the task and email detail
- Seeing which emails belong to a task, or which tasks an email was linked to, meant scanning the whole history for reference notes.
Add a Linked list built from those notes: collectLinkedReferences gathers every reference from the given texts in order, lists each object once and skips the object's reference to itself. The task view reads its description and history notes and shows the list in the main column, just above the history. The email information panel reads its history notes and shows the list below the put back / change button. The list is hidden when there is nothing linked.
- (action-history) Recognise reply and forward notes in email history
- The server records replies and forwards as data changes with a fixed note: "Reply: E#n" on the original when a reply is created, "Forward: E#n" when it is forwarded, and "Reply to: E#n" on the new email. They rendered as "Data changed" with a database icon and the prefix as loose text before the chip.
parseLinkNote now reads those notes as a relation instead of text. The row gets a reply, forward or mail icon, the email card gets a "Reply", "Forwarded as" or "Original email" label, and no text is shown beside it. The Linked lists keep the relation for each email they list. "Reply to" is labelled "Original email" because the server writes the same note on a forward too, so the note alone cannot tell the two apart. The Czech "Reply" needs a message context, as the existing string is the button's verb.
- (action-history) Join an email card and its preview into one panel
- With embedEmailPreview on, a history note showed the email's card, then the preview floating below it at full width, then a full-width green Reply bar, so the three read as unrelated blocks.
They are now one bordered panel. The card becomes the panel's header (an attached variant without its own border, with a divider below), the preview is the body, and Reply is a compact button in a right-aligned footer, shown only for an inbound email that has no reply yet. The header carries the reply or forward label, and it already shows the loading and unavailable states, so the panel drops its own loading and "Could not load email." lines. Task, person and call references in the same note stay plain cards.
- (email) Add a cc field to the email composer
- The composer only offered To and BCC, although emails already carry a
copylist that the viewer and headers show andemail.editaccepts. Agents had no way to copy someone in on a reply or a new email.
- The composer only offered To and BCC, although emails already carry a
The BCC dropdown was already a generic address list, so it becomes
AddressListField, parameterised by the email field it patches, and renders
once for copy and once for bcc.
- (email) Suggest lookup contacts in the cc and bcc fields
- The To field offers the queue's lookup records as suggestions, so an agent can pick a contact instead of typing the whole address. CC and BCC only accepted typed addresses.
The lookup query moves out of RecipientsField into useEmailLookupOptions,
which all three fields now use. They share the emailLookup query key, so
the records are fetched once per lookup.
- (email) Commit typed addresses on a separator and clear them once added
- Adding an address that is not in the lookup left the typed text in the input next to the new chip. The dropdown's search query is controlled, so Semantic's own reset after a selection never reaches it. The query is now cleared when the added address is exactly what was typed. Picking a suggestion with a partial query keeps the query, so several contacts matching it can be picked in a row.
Typing a comma or semicolon turns the finished addresses before it into chips, the way mail clients do. Anything before the separator that is not an address stays in the input to be fixed.
To, CC and BCC were three copies of the same dropdown, so RecipientsField folds into AddressListField and the parsing moves to tested pure functions. The blur split now treats whitespace as a separator in CC and BCC as it already did in To.
- (email) Show the cc field only on a backend that supports it
- Saving cc needs UCS 5.14.0. An older backend rejects the
copykey, and patchEmail drops UNKNOWN_KEY errors silently, so the field would appear to work while nothing stuck.
- Saving cc needs UCS 5.14.0. An older backend rejects the
The backend already reports its version in whoami. The session's parser now keeps it, and useBackendVersionAtLeast compares it with ucsRequiredVersion -- the check the startup saga uses for the minimum UCS version, which also lets a devel build through. The helper had no tests, so it gets them here.
- (email) Preview attachments in a tooltip instead of a hover popover
- Each popover ran its own open delay, so moving the pointer from one attachment thumbnail to the next waited the full delay every time. The tooltips share the app-wide TooltipProvider, which opens the next tooltip instantly once one is already showing.
The Tooltip primitive gains a className that replaces its default max-width, so the preview can be capped at max-w-lg (and left uncapped for the fixed-width PDF embed). The 200ms closeDelay is gone: Base UI's tooltip trigger bridges the gap to a hoverable popup with a safe polygon.
🐛 Bug Fixes
- (action-history) Detect reply and forward references inside longer notes
- A "Reply:", "Reply to:" or "Forward:" prefix was only recognised when it was the whole note, so "lorem Forward: E#123 ipsum" lost the forward label and showed as a plain mention.
The relation now belongs to each reference rather than to the note: parseLinkNote finds every prefix anywhere in the text and labels only the email reference right after it, while other references in the same note stay plain. The note's text is still shown when anything beyond the prefixed references remains. The feed icon takes the first relation in the note, and each embedded email panel and Linked list entry carries its own label.
- (email) Add the czech translation for the cc placeholder
- The cc field's placeholder came without a catalog entry, so the strict lingui compile in CI failed on a missing cs translation. It reads "CC" in Czech, the same as the existing "BCC".
🚜 Refactor
- (references) Share one parser for E#, T#, C# and A# references
- The reference pattern was copied into the References chips component, the embedded email preview and the Lexical transformer, each with its own regex. Add parseReferences, which returns every distinct reference in a text in order of appearance with its kind and id, and hasTextBesideReferences, which tells a note made only of references from one that carries context. The References component now splits text with the shared pattern.
Also drop the stray brace in the Reference template literal type, which made every E#/T#/C# value fail to match it.
🎨 Styling
- (action-history) Show history comments as notes instead of chat bubbles
- History comments used the chat Bubble: an orange, chat-shaped box that did not sit well next to the field changes and the new reference cards.
Comments now render as a neutral note in the cards' visual language: the secondary background, the same thin border and radius, a brand-coloured accent on the left and the cards' 36rem width cap. Bubble stays as it is, since Chat still uses it.
[9.46.0] - 2026-10-07
🚀 Features
- (dispatch-bridge) Ring unattended bridge calls for ip phone users
- The unattended bridge ring lived in UPhoneInternal, which only mounts when the user's device is a uPhone. Clients running the operator next to an IP phone therefore never heard that a bridge call was waiting in their dispatching workspace, although the IP phone itself has no way to signal it.
The ring moves into its own UnattendedBridgeRing component mounted once in ReadyState, so it no longer depends on the device type. It plays only in the main app, which is the condition UPhone used to apply by mounting UPhoneInternal there. UPhoneInternal keeps the regular ring tone, the auto-answer sound and WebRTC unchanged.
SoftPhone devices are excluded: they had no bridge ring before and stay as they were. Without a uPhone or SoftPhone nothing resolves the saved ring device, so IP phone users hear the ring on the browser's default output.
- (email) Link an email to an existing task from the email tab
- Tasks only pick up an email when it is created from it. Linking one to a task
that already exists meant copying E#
from the tab label and pasting it into a task comment by hand.
- Tasks only pick up an email when it is created from it. Linking one to a task
that already exists meant copying E#
A "Link task" button now sits next to "Create task" in the email tab's action
bar and in the reply editor. It opens a dialog in which the agent either
types a task name (or T#
useCRMSearch is split so the dialog can search with its own needle and the object type filter crm.search already accepts. The crm.tasks.edit typing gains the optional fields and comment it is already called with, and crm.tasks.list and crm.search's type filter are typed.
- (email) Filter the link task dialog by person
- Most of the time the task to link belongs to the customer who sent the email, so the dialog now has a person filter, pre-filled with the email's first person. It searches persons through crm.search and can be cleared to fall back to the newest tasks overall.
crm.tasks.list has no person condition and its validator rejects unknown keys, so with a person set the candidates come from crm.persons.getMany instead: the person's own tasks (not their company's), limited to task queues the agent sees and filtered client side by queue, own tasks and open/closed state, newest first.
The dialog also opens at the lg size instead of md, and the filters sit in a fixed grid -- person and queue side by side, both switches on their own row -- instead of a wrapping flex row that left one switch next to the queue field and the other on a line of its own.
- (email) Move link task into a menu next to create task
- Create task and Link task sat side by side as two equal buttons in the email action bar and the reply editor. Linking is the less common way to tie an email to a task, so it now lives behind a chevron next to Create task, which reads as "more ways to do the same thing" in a way a kebab menu would not.
EmailTaskButton renders the pair as one Semantic button group and owns the link task dialog, now a controlled LinkTaskDialog. The menu only appears when the team creates tasks internally: for an external target (window or tab) or with tasks disabled there is no internal task to link to, so the plain Create task button renders as before. The target lookup moves out of TaskButton into useTaskTarget so both can read it.
lib/ui gains a menu: a port of the shadcn/ui dropdown menu on Base UI's Menu, one part per file and limited to the parts in use (Menu, MenuTrigger, MenuContent, MenuItem). It portals into the app root and sizes off data-density like the combobox and popover.
🐛 Bug Fixes
- (dispatch-bridge) Silence unattended bridge ring during the user's own call
- The bridge ring was gated on the agent status alone. The backend can keep an agent in the ready status while a direct call rings them, so the bridge tone and the regular ring played on top of each other. That overlap already existed for uPhone before the ring moved out of UPhoneInternal.
UnattendedBridgeRing now also stays silent while the user has an active call, ringing or connected, and resumes once it ends if the bridge is still unattended. It reads the single activeCall rather than activeCalls, so a spy leg started by someone else does not mute it.
Adds Storybook benches for the two audio paths that are hard to reach in a running app: every input of the bridge ring, and the uPhone auto-answer beep, which otherwise needs the backend to flag a call as auto-answered.
- Bridge now rings on softphone, update tests
- (email) Clear the selected task when the link task filters change
- A task picked in the dialog stayed selected after the search or a filter changed, so the agent could hide it from the list and still submit it without seeing what they were linking.
Changing the search or any filter now clears the selection, and the submit button and row highlight only honour a selection that is in the list on screen, which also covers the list changing underneath through a refetch.
🚜 Refactor
- (dispatch-bridge) Move unattended bridge ringing out of feature flags
- Ringing for unattended bridge calls is a setting a deployment chooses, not
an experiment waiting to be removed, so it does not belong under
ff. The option is now read as top-levelunattendedBridgeRingingand still defaults to false.
- Ringing for unattended bridge calls is a setting a deployment chooses, not
an experiment waiting to be removed, so it does not belong under
The config schema strips unknown keys, so a config that keeps the old
ff.unattendedBridgeRinging is silently ignored and has to move the key to
the top level. The flag only ever existed on this branch, so no released
config is affected.
⚡ Performance
- (dispatch-bridge) Mount bridge ring only when its feature flag is on
- UnattendedBridgeRing was mounted in every tab regardless of ff.unattendedBridgeRinging and only consulted the flag when deciding whether to play. With the flag off it could never ring, yet it still polled the role query, preloaded the ring tone, set the audio sink with its device telemetry and re-rendered on agent status, call and softphone changes.
ReadyState now gates the mount on the flag, so none of that happens when the feature is disabled. The check inside shouldRingBridge became unreachable and is removed.
The storybook bench applies the same gate so its flag control keeps doing something, and treats a missing audio element as silent.
[9.45.0] - 2026-10-07
🚀 Features
- (tabs) Default name and icon for unnamed component tabs
- (ui) Add a Badge primitive ported from shadcn/ui
- Port of the Base UI flavour of the shadcn/ui badge, so new code has a
Tailwind-styled label that does not go through Semantic's Label the way
InlineBadge does. It keeps upstream's variants (default, secondary,
destructive, outline, ghost, link), its data-icon padding hooks and its
renderprop via Base UI's useRender, so a badge can render as a link.
- Port of the Base UI flavour of the shadcn/ui badge, so new code has a
Tailwind-styled label that does not go through Semantic's Label the way
InlineBadge does. It keeps upstream's variants (default, secondary,
destructive, outline, ghost, link), its data-icon padding hooks and its
class-variance-authority is not a dependency here, so the variants are a
plain record of class strings joined with cx. shadcn's tokens map onto the
palette: primary to accent, secondary to the tertiary background, destructive
to negative, border to the primary border, muted to the secondary
background, ring to focus. The dark variant goes through in-[.dark],
since no dark: variant is configured.
Sizes are upstream's for now; density support is still to be decided.
- (ui) Add a combobox
- Port the shadcn/ui combobox built on Base UI into lib/ui, one part per file, drawn to match the select: the same control frame and open state, the same panel surface, portal to the app root and fixed positioning, and the same density-driven rows, highlight and check mark.
Styled with Tailwind rather than an SCSS module like the select. Upstream leans on an InputGroup we do not have, so ComboboxInput draws the frame itself over Base UI's own InputGroup. ComboboxTrigger takes the select trigger's field/bare variant, so a searchable select with the input inside the popup composes from our parts. ComboboxChips forwards its ref because React 18 does not pass one to a function component, and the chips need it as the popup anchor.
- (email) Add text highlight button to the email editor
- The colors plugin was already enabled, but only its textColor button was on the toolbar; backgroundColor ships in the same plugin, so exposing highlighting is a toolbar change only. It emits an inline background-color span, which email clients render. Clear Formatting removes it, Unify Formatting leaves it alone.
- (crm) Highlight a contact row on hover
- The edit and delete buttons sit at the far right of each contact row, away from the contact they act on, so in a long list it was easy to lose track of which row a button belonged to. Give the hovered row the palette's item hover background so the target is obvious before clicking.
The rule lives on a new contactRow class rather than on contact, because the add-contact row in PersonContacts reuses contact and should not light up.
- (task) Add contact and task data columns to the task allocation widget
- The widget settings get a "Show task contact" switch and a multi-select of task data fields. Both are off by default, so existing widgets look the same. The field options are the union of the metadata definitions on the selected queues, or on all task queues in own-tasks mode. Button fields are left out because the whole row already navigates to the task. A selected field whose queue is gone or no longer visible stays in the picker as "Unknown (id: ...)" via extendUnknownOptions, so it can still be removed.
The contact column renders each linked person's name with PersonName, as plain text, without the reference label or a link that would compete with the row's navigation. Every data column renders through MetadataViewer using the definition from the task's own queue, since select labels can differ between queues, and stays empty where that queue does not define the field.
taskListParser now reads persons and data. crm.tasks.list sends
data: null for tasks without custom data, so null maps to an empty record.
Both fields only feed display columns, so anything else unexpected falls
back to empty through .catch() rather than failing the parse. A failed
parse would have rejected the whole list: react-query retried while
keepPreviousData kept the previous queue's tasks on screen.
The queue cell is now always rendered when the queue column is enabled. Before, a task whose queue was unknown lost the cell and every later column shifted one place left, which the new trailing columns would have made visible.
- (task) Group closing states in the task state select
- Task states are configured per queue, but each carries a built-in type, and a queue may have any number of closed states. The state select gave no hint which of them closes the task -- the one type that changes what happens next, since a closed task leaves the task queue's default view.
Open states stay listed flat in position order. Closing states follow under a separator and a "Closes the task" label; a queue without any keeps the plain list, and one with only closing states shows the label without the separator. The other types are not marked: the admin's state names already say as much, and a heading per type mostly repeated a single state's name and pushed the list into scrolling.
- (task) Pre-fill task subject from the email subject
- Creating a task from an email opened the form with an empty subject, so agents retyped what the email already says. TaskButton now passes the email subject through addTask into addNewTask, which seeds the new task's subject with it. It stays an ordinary editable value; other entry points (call, person) still start empty.
- (tabs) Log why a temporary tab was closed automatically
- A customer reported that their call CRM tab disappeared when another agent answered a different call. Nothing on the closing path wrote to the remote log, so there was no way to tell which event closed it.
Automatic closes now go through autoRemoveTempTabRequest and autoRemoveTempTabByMarkRequest, which carry a reason. The tabs saga logs it via api.log.info only after it has found the tab, so the hangup handler, which sees every agent's calls, does not flood the log with misses. Manual closes keep using the old action creators and stay silent.
Reasons logged:
- call.cancel / unanswered call.hangup, with the event's call id, uniqueid, ucrm_interaction_id, connected, caller and connected lines, called lines and connected user (missing values as null, so an absent interaction id is visible)
- person merge, with the old and new person id
- inactivity auto-close, with the last visit time
- (tabs) Include the event name in logged call details
- The serialized call event in the auto-close log carried the call's fields but not which event it was, so the line had to be read together with its prose prefix. describeCallEvent now takes the whole saga action and writes the websocket method name (call.cancel, call.hangup, ...) as its first field, recovered from the EVENT_* action type the way ucsEventToMutation built it.
- (tabs) Trace automatic temporary tab closes
- Some deployments collect the legacy api.log, others only Grafana traces, so the auto-close record from the previous commits was invisible on the latter. Next to the log line, removeTempTab now ends a tab.auto_close span through the shared OTel tracer, which App.tsx already exports to /otel/v1/traces.
To make the span filterable, an automatic close now carries a short reason and a flat details object instead of one prose string:
- cancelled for this user / hung up unanswered, with the call event fields (callEventDetails, formerly describeCallEvent, now returns the object)
- person merged, with old_person_id and new_person_id
- inactive, with last_visited_at
The span carries app.tab.id, app.tab.mark, app.tab.close.reason and one
app.tab.close.
🐛 Bug Fixes
- (store) Drop the duplicate AppDispatch import in outgoing queue actions
- The dispatch typing refactor added the type import to OutgoingQueue/actions.tsx twice. Babel rejects the redeclared identifier, so the file failed to parse, oxlint reported it as an error and the unit tests that import it could not load.
- (call-detail) Allow call tags without an AI decision
- decision and justification only make sense for a tag the AI assigned to a call -- a tag applied by a user or another integrated system carries neither. The parser required decision unconditionally, so a non-AI tag would fail to parse. Make decision nullable, and render those tags as a plain label with no decision color or justification popup.
- (ui) Give Badge a whole-pixel line box and roomier padding
- With text-sm on the 14px root, leading-none left Badge a 12.25px line box, so its height landed on a fractional pixel and the text sat blurry or off centre depending on where the badge fell. A fixed 12px line height keeps the box on the pixel grid, and py-1 restores the vertical padding the switch to py-0.5 had squeezed out of the old Semantic label.
- (task) Lay out task detail tags in a wrapping row
- TagRenderer returns a bare fragment, and the right-hand column of the task detail is a column Flexbox, so every tag became its own flex item and the tags stacked one per line. Wrap them in a wrapping flex row so they sit side by side and break only when the column runs out of width.
The wrapper lives at the call site rather than in TagRenderer, since the call history, task queue, email and interaction views place the tags inline and an extra block would break those rows. It is skipped when the task has no tags so the empty column slot does not add a stray gap.
- (email) Keep close-selected button visible outside the inbound filter
- The supervisor toolbar mounted the "Close selected" button only while the filter showed inbound emails, so switching the filter made the button vanish and the neighbouring controls shift sideways. Render it always and disable it instead; rejectSelectedEmails still guards on the inbound filter itself.
- (widget-link) Open on-call links once per call instead of every render
- A Link widget with openOnCall called openLink straight from render, so every re-render during a call -- and activeCall is replaced on each call-state update -- opened another browser window or temp tab.
Opening now happens in an effect keyed on the call's uniqueid and the link. The set of already-opened keys lives at module level rather than in a ref so that a widget remounting mid-call (tab or layout switch) and StrictMode's double effect run do not open the link again.
- (store) Keep selectTaskQueues typed as task queues
- selectTaskQueues used the data-last R.filter(isTaskQueue) on its own. Outside
a pipe, remeda has no data to infer the array type from, so it falls back to
ReadonlyArray
. The Queue type guard then does not fit the predicate parameter, and the selector came out typed as unknown, which left every caller with 'queues' is of type 'unknown'errors. Filtering inline with Array.prototype.filter keeps the type guard, so the selector returns TaskQueue[].
- selectTaskQueues used the data-last R.filter(isTaskQueue) on its own. Outside
a pipe, remeda has no data to infer the array type from, so it falls back to
ReadonlyArray
- (task) Keep stale queue selections removable in task allocation settings
- A multiple-select Dropdown renders a label only for values that match an option. When an agent loses access to a task queue, or the queue is deleted, its id stays in selectedQueues with no option and therefore no label to remove it by. Run the options through extendUnknownOptions, as the SMS widget settings and the task data column picker do, so such a queue shows as "Unknown (id: ...)" and can be removed.
- (ui) Keep select separators visible in a scrolling list
- The select list is a flex column with a max height. Once its options overflow, flex shrinks every child to fit, and a separator has no content to hold it open, so its 1px collapsed to 0 and it disappeared. The Grouped story never overflows, which is why it went unnoticed.
- (widget-link) Forget opened on-call links once their call has ended
- The set that remembers which on-call links were already opened lives at module level and only ever grew: one key per call and link for as long as the operator kept the app open.
A sweep now runs 30 minutes after a key is added and drops the keys whose call is no longer among the live calls. It re-arms only while keys remain, so no timer is left pending once the set is empty. The timer sits beside the set rather than in an effect, because the widget remounts on tab and layout switches and a per-component timer would be reset before it ever fired.
Keys are compared against activeCalls rather than activeCall: a held call stops being the active one while the operator talks to somebody else and becomes active again afterwards, and dropping its key in between would open its link a second time.
- (supervisor) Fit the talk time formulas in their tooltips
- The talk time column tooltips set the label and the KaTeX formula side by side inside the tooltip's 20rem cap. A display-mode fraction cannot wrap, so both formulas ran past the popup's edge, the Czech "Prohovořeno %" one by more than 200px.
MetricFormula now stacks a bold title above the formula, renders KaTeX at
the tooltip's text size instead of its default 1.21em and drops the
display margins. That alone fits the talk time formula. The talk time to
logged time denominator is still about 360px wide, so Tooltip gains a
wide prop that swaps the 20rem cap for the viewport width minus 2rem,
and PurePopup passes it through for these two tooltips.
- (tabs) Keep the selected tab marked while the menu is unlocked
- Unlocking the tab menu blanked the background of every row, the selected one included, so there was no way to tell which tab the widgets on screen belonged to while editing them. Only the hover fill is dropped now; the selected row keeps its background, held steady on hover because the input and the buttons carry their own.
The name input used to mark hover with a border alone, which is lost on a filled row, so it takes the input background as well. Unlocked rows gain a little side padding so the input and the drag handle do not sit flush against the edge of the fill.
🚜 Refactor
- (store) Type dispatch so it accepts thunks and interface actions
- Components called plain useDispatch(), which is typed for UnknownAction only. Every thunk dispatched from a component and every action described by an interface (INotifyRequestAction and friends, which lack an index signature) was a type error, and the thunks themselves took untyped dispatch and getState parameters. That was about 220 of the errors outside tests.
storeUtils now exports AppDispatch and AppThunk over the plain Action type, useAppDispatch alongside useAppSelector, and a useAppStore whose dispatch knows thunks. useAppDispatch is an alias rather than useDispatch.withTypes() because tests mock react-redux and withTypes does not exist on the mock at import time.
Components that failed switch to useAppDispatch; the rest keep useDispatch for now. Thunk creators annotate their parameters rather than their return type, so the dispatch result keeps being inferred for callers that chain on it.
ConfirmData becomes a union: either an action or a function returning an action or thunk, or any callback paired with a dispatcher that swallows its result, which is how FormEditor, AgentProfiles and the checklist use it. ConfirmModalContent now checks data.dispatcher before calling dispatch() so the union narrows; behaviour is unchanged.
Typing getState surfaced a missing type on AlterAllQueueRequest and a
missing fullText on CallHistoryData, both fixed here. The event thunk
reads the user id with ?? null since the typed getIn rejects a null
default.
Four put(thunk) calls in crm.saga and softPhone.saga remain, since redux-saga's put does not accept thunks at the type level.
- (store) Replace the remaining useSelector calls with useAppSelector
- Plain useSelector types the state as unknown, so every selector written as ({ app }) => ... or ({ ucs }) => ... was an error and everything read through it was any. The last 23 files move to useAppSelector, so no component in src reads the store untyped any more.
That removes 19 errors net rather than the ~50 the untyped selectors accounted for: with the state typed, code downstream of the selector is checked for the first time. Most of what appears is Immutable reads that may be undefined or index with a plain string instead of a key of the record, concentrated in the QueueStatus summarizers (Rate.tsx goes from 9 to 21). Those are real findings that belong to typing the Immutable state, not to this change.
TabContent.test spied on reactRedux.useSelector after the fact, which never reaches useAppSelector since it holds the mock from vi.mock. It now sets the return value on that mock directly.
The oxlint errors already present in the touched files are cleared as well, without changing behaviour: explicit values on the Term and OfEnum enums matching their implicit ones, the u flag on Link's template regexes (with the braces escaped, which unicode mode requires, and checked to expand a sample template identically), an unused getPath and useForm import, a lonely if, a useless fragment and an empty props type.
- (tabs) Merge generateDefaultName and generateDefaultIcon
- Both functions ran the identical component-tab guard clause and looked up the same key (tab.content.component) in a parallel map, one keyed to a MessageDescriptor and the other to a LucideIcon. Collapse them into generateDefaultTabInfo, backed by a single componentDefaultsMap of { name, icon } pairs, so the two lookups can't drift out of sync and TabContent only needs one memoized call instead of two.
- (ui) Replace InlineBadge with Badge
- The tab counters were the only real user of InlineBadge, a Semantic Label with an SCSS override, so they move to the new Badge and InlineBadge goes.
Badge takes over the Semantic label's metrics instead of upstream's: text at 0.89em so it follows the surrounding text rather than a fixed rem size, line-height 1, bold, a 0.25rem radius and no fixed height. With the size in em it also scales with display density without variants of its own.
Tab names may pick a counter colour as %path:green% from Semantic's
thirteen names (red by default). The inline backgroundColor that went along
with it never applied, since Semantic sets label colours with !important,
so the colours now come from the nearest Tailwind shade, looked up in
TabContent. The shades shift slightly from Semantic's, and the tabs no
longer depend on SemanticCOLORS.
To let a caller bring its own colours without !, Badge accepts
variant={null} for no variant classes at all, as cva does upstream. The
counter then sets the background and text-inversed-primary itself.
The aria-invalid styling from upstream is dropped: nothing sets it on a badge.
- (ui) Size Badge text on the Tailwind scale
- Badge took its text size as an arbitrary 0.89em, carried over from the Semantic label it replaced. text-sm is the nearest step on Tailwind's own scale and differs by about a quarter of a pixel next to the 14px tab text, which is where Badge is used today.
The size is now fixed in rem, so Badge no longer follows the surrounding font size or display density on its own. The tabs scale with neither, so nothing changes there; a surface that does will need Badge sized for it.
- (ui) Replace plain hover popups with the lib/ui tooltip
- Every Semantic Popup that only showed short text on hover over an icon, a label or a span now uses the props-driven Tooltip from lib/ui. PurePopup switches internally, which covers the thirteen header icons in the supervisor overview. Popups that open on click, hold forms or menus, sit on table cells or need a disabled or controlled state stay on the Semantic wrapper for now.
In the report download fields the tooltip moves inside the
The explicit "top center" positions go, as that is the Tooltip default.
-
Remove unused caps lock module
- Its only consumer, the old Login form, went away in 24ca950d8 when SessionManager took over the login flow, leaving the module unreferenced. The tooltip swap in e04a662bc still migrated it, which made a change nobody could see in the app.
-
(crm) Draw contact edit and delete icons with lucide
- The edit and delete buttons on a CRM contact row used Semantic UI font
icons. Render lucide-react Pencil and Trash2 instead, as part of moving
icons off Semantic. The buttons stay Semantic icon buttons so the existing
button.ui.icon.button.simplestyling still applies; delete becomes a bin rather than a cross, which says more clearly that it removes the contact.
- The edit and delete buttons on a CRM contact row used Semantic UI font
icons. Render lucide-react Pencil and Trash2 instead, as part of moving
icons off Semantic. The buttons stay Semantic icon buttons so the existing
-
(crm) Split person name, link and reference into composable parts
- Showing just a person's name, or a link without the CRM reference label, needed more flags on PersonNameView next to isLink, and each flag would double its combinations. Split it into parts that compose instead:
-
personQueryOptions (src/queries/person.ts) owns the person query key and fetch, following emailQueryOptions.
-
PersonName renders just the display name.
-
PersonLink wraps any content in a tab link to the person.
-
PersonNameView keeps rendering the reference label and the name, now through PersonLink and the shared query options. Its callers are unchanged.
Link without reference is then
The query function still returns the raw crm.persons.get response, so the cached shape stays the same for CRMPersonSelection and CRMPersonMerging, which share the key with their own fetchers. Moving them onto the options is left for a separate change.
🎨 Styling
- (task) Render task allocation widget parts with ternaries
- Conditional rendering in the widget mixed
cond && <X />with ternaries. Convert the remaining state, agent, queue picker and allocate header renders tocond ? <X /> : null, so the convention is consistent and a falsy left operand can never render a stray value. The state cell's name collapses toselectedState?.name, which renders the same.
- Conditional rendering in the widget mixed
⚙️ Miscellaneous Tasks
- Satisfy oxlint in files touched by the tooltip swap
- The tooltip refactor brought Tag and ActionHistoryRow into lint:changed, which surfaced errors that predate it.
Tag returned an empty fragment for unknown tag ids inside a map, which cannot carry a key; it now returns null. Its selectedTags default was a fresh array literal on every render, now a module-level constant.
ActionHistoryRow listed the whole useMutation result as a useCallback dependency. That object is a new reference each render, so handleSave was never memoised. Destructuring mutate, error and isPending gives the callback a stable dependency.
- Satisfy oxlint in the task detail view
- The person list rendered PersonNameView without a key, so React could not match the items across re-renders, and a fragment wrapped the lone LexicalEditor in the solution field for no reason. Key the list by person_id and drop the fragment. oxfmt also rewraps the deadline initial value, which was already out of format.
- (task) Satisfy oxlint in the task allocation widget
- (crm) Satisfy oxlint
- The person reload in handlePersonMerge caught its error into a binding it never read, which oxlint reports as no-unused-vars. Use an optional catch binding instead; behaviour is unchanged.
[9.44.0] - 2026-10-01
🚀 Features
- (email) Gate paste paragraph-spacing fix behind a feature flag
- The e-mail module is customer-sensitive right now, so give the paragraph-spacing paste fix an off-ramp that does not require a code revert: config.ff.preservePasteParagraphSpacing, defaulting to true. Turning it off restores the exact pre-fix paste behaviour, since the guard wraps only the new paste.before/paste.beforeCleanup handlers and touches nothing else in the editor config.
- (email) Show inline thumbnails for image attachments
- Image attachments got a generic file icon like everything else, even
though a small preview is far more useful for spotting the right one at
a glance. Show a hover-popover thumbnail for image mime types instead,
falling back to the existing per-mime-type icon for everything else
(including HEIC/HEIF, which browsers can't decode in an
).
- Image attachments got a generic file icon like everything else, even
though a small preview is far more useful for spotting the right one at
a glance. Show a hover-popover thumbnail for image mime types instead,
falling back to the existing per-mime-type icon for everything else
(including HEIC/HEIF, which browsers can't decode in an
The thumbnail-or-icon rendering is shared between the reply/template attachment row (AttachmentItem) and the read-only email attachment list (AttachmentView/AttachmentViewItem) via a new AttachmentThumbnail component, to avoid keeping two copies of the same Popover logic in sync.
- (email) Add download action and inline badge to attachment row
- The reply/template attachment row had no way to get the file back once attached, only remove it, and gave no indication that an attachment was inline (embedded in the body) rather than a regular one. Add an explicit download link/icon alongside remove, and a small "inline" badge for inline attachments, matching what the read-only email attachment list already conveys.
- (email) Highlight attachment row on hover
- The download and remove icons act on their whole row, but with several attachments stacked closely together it wasn't obvious which row an action would apply to. Highlight the row background on hover, using the same --item-hover-background-color list rows use elsewhere.
- (email) Open attachment thumbnail previews to the side
- Attachments stack directly on top of each other, and the preview popover opened below the thumbnail by default -- covering the next attachment down and forcing the cursor off the trigger (closing the popover) before it could reach it. Open it to the side instead, so moving the cursor straight down the thumbnail column reaches the next preview without ever leaving hover.
Both the composer's attachment row (AttachmentItem) and the read-only attachment list (AttachmentViewItem) open to the left: the composer row's thumbnail sits next to its filename, which a right-opening popover would cover, and the read-only list lives in a narrow sidebar pinned to the right edge of the page, where opening right would clip off-screen.
- (email) Make the composer attachment name a link
- The download icon was the only way to open an attachment, easy to miss next to remove. Make the filename itself a link to the same URL, opening in a new tab so an image previews there instead of always saving to disk, matching how the read-only attachment list (AttachmentViewItem) already behaves.
- (email) Enlarge the attachment thumbnail preview popover
- 288px was small enough to make text in screenshots and scanned documents hard to read. Give the preview image a fixed width (not max-w, which only ever shrinks an image and never enlarges one past its natural pixel size) and let height stay auto, so the box still follows each image's own aspect ratio instead of always being square.
Override the popover's own default max-w-(--available-width)/ max-h-(--available-height) with max-w-lg and max-h-192 (Tailwind's canonical names for 32rem and 48rem respectively) -- a portrait page (A4's 297:210 ratio) comes out to ~45rem tall at 32rem wide, past max-height's default named scale which stops at max-h-96 (24rem). Tailwind orders the CSS-var default before a plain utility of the same property, so this override wins without needing !important.
- (ui) Add a dialog primitive to lib/ui
- Port of the shadcn/ui dialog built on Base UI, meant to replace the Semantic Modal from lib/semantic-portal-attached one call site at a time. Part names, composition and data-slot attributes follow upstream, one part per file.
The portal targets the app root instead of , so the dialog picks up the theme class and data-density. The overlay and popup sit at z-index 1000/1001, Semantic's dimmer and modal, so Semantic popups and our popover still open above it. The overlay keeps Semantic's 85% black.
DialogContent takes a size (sm, md, lg, xl, full) instead of upstream's fixed max-w-sm; most of our modals are wide, so lg is the default. Padding, section gaps, header gap, title and description type and the close button scale with the display density. The close button is 44px on touch, written in px because a rem here is Semantic's 14px. The footer is a plain button row, without upstream's tinted, bordered bleed.
- (ui) Add a props-driven tooltip to lib/ui
- Hover hints across the app still go through Semantic's Popup. This adds the
replacement:
<Tooltip trigger={...}>content</Tooltip>, built on Base UI and styled after shadcn/ui's Base UI tooltip, but with a single props API instead of upstream's compound parts.
- Hover hints across the app still go through Semantic's Popup. This adds the
replacement:
The trigger is always wrapped in a span that Base UI anchors to, because Semantic components such as Icon do not forward a ref and would otherwise leave the popup unpositioned -- keeping the span inside the component means no call site can forget it.
The look follows Semantic's popup (white surface, border, popup shadow, a lighter surface in the dark theme) so old and new hints read the same while the migration is under way. The popup portals into the app root to pick up the theme and data-density, and its type scales with the density.
TooltipProvider is mounted once in ElementRoot. Without it every tooltip waits Base UI's default 600 ms before opening, while Semantic's popups open after 50 ms.
Base UI disables tooltips on touch input, so on touch panels these hints do not show; whether infotips should move to a hover-opened popover instead is still open.
- (email) Add download-all button for attachments
- Bundle every non-inline attachment on an email into a single zip via jszip, client-side, so recipients no longer have to click each attachment individually. Inline attachments (embedded body images) are excluded from the bundle by default, since the hideInlineAttachments display flag only controls what is listed, not what should be downloadable in bulk -- unless there are no non-inline attachments at all, in which case the button falls back to bundling everything rather than producing an empty zip.
Styled with lib/ui primitives (Popover, Items) and lucide-react icons rather than semantic-ui-react, in line with the tailwind + base-ui rewrite. A chevron dropdown next to the primary "Download all" action offers two more combinations: bundling inline attachments into the zip too, and downloading every non-inline attachment individually instead of zipping. The zip filename is derived from the sanitized email subject instead of a hardcoded "attachments.zip".
- (email) Add individual download variant including inline
- Round out the attachment dropdown with a fourth combination: individual (no zip) downloads that also include inline attachments, alongside the existing all/individual x ordinary/including-inline options. downloadAttachmentsIndividually now takes the same includeInline option downloadAllAttachments already had.
Drive all four download actions through a single useMutation keyed on a DownloadVariant union instead of a hand-rolled isDownloading/zipError state pair and a runDownload wrapper, since none of that pattern had shipped yet and there was no reason to keep it once every action needed the same isPending/isError handling.
-
(email) Preview PDF, video and zip attachments too
- The hover-popover preview only covered images, falling back to a plain file icon for everything else. Extend it to three more common attachment kinds:
-
PDF: the browser's own inline PDF viewer via
-
video: a native
-
zip: a list of the archive's filenames, read from the zip's central directory (stored uncompressed, so entries don't need decompressing to list them).
Added jszip for the zip listing -- its loadAsync()/files API gives filenames directly without touching entry contents, which is less code on our side than parsing the ZIP format by hand or reaching for a lower-level library like fflate.
The trigger for these three kinds is still the plain file-type icon (there's no cheap way to derive a small thumbnail for them the way there is for images); only the popover content differs by kind.
Image and video previews use max-w-full/max-h-192, not a fixed size -- a small attachment stays its natural size, only something bigger than the popover's own cap actually shrinks to fit. The PDF embed is the one exception: it has no natural size to speak of, so it always fills the available width instead, and the popover gets a min-w-lg floor just for that case, since an
🐛 Bug Fixes
- (email) Preserve blank lines between paragraphs on rich paste
- Froala runs in ENTER_BR mode (enter: 2), so on paste it flattens every
block element to a single line break, whether or not the source had a
visual paragraph gap. A rich-text clipboard payload expresses a blank
line only as adjacent
/
boundaries with nothing empty between them, so that boundary collapsed to one
instead of two and every paragraph ran together. Plain-text paste was unaffected because the browser gives it an explicit empty line instead.
- Froala runs in ENTER_BR mode (enter: 2), so on paste it flattens every
block element to a single line break, whether or not the source had a
visual paragraph gap. A rich-text clipboard payload expresses a blank
line only as adjacent
Froala's own pastePlain conversion (queue-level plain_paste setting) funnels through the same paste.afterCleanup pipeline before insertion, so it hit the identical bug and gets the same fix.
Added preserveParagraphSpacing (email-utils.ts) to turn a boundary
between two block elements into a blank line while leaving a real
in-block
(e.g. a signature's line break) untouched, and wired it
into paste.afterCleanup.
-
(email) Recurse into nested paste content and cap stacked breaks
- Code review turned up three real gaps in the previous commit:
-
A whitespace-only text node between two block tags (common in any non-minified clipboard HTML, e.g. Word/Outlook) reset the adjacency tracker, so the fix silently no-op'd outside the exact minified shape it was tested against. Such nodes are now skipped and removed.
-
Only doc.body's direct children were inspected, so a wrapper element around the paragraphs -- how Gmail commonly wraps a pasted message in a single
with per-paragraphchildren nested inside it -- left the nested structure untouched. The function now recurses into every container instead of stopping at the top level. -
An already-empty paragraph (Word/Outlook's usual encoding of an existing blank line, e.g.
) kept its own
in addition to the ones this module inserts around it, stacking up to 5 breaks for one intended blank line. A final pass now caps any run of consecutive
at 2.
Moved the hook from paste.afterCleanup to paste.beforeCleanup: testing the nested-wrapper case live turned up a second issue -- Froala's own internal cleanup already collapses nested empty-
before afterCleanup fires (unlike flat top-level
tags, which survive untouched until Froala's later BR- mode insertion). By the time afterCleanup saw it, the paragraph-gap signal was already gone. beforeCleanup receives the untouched clipboard HTML, before Froala's own cleanup has a chance to destroy it.
- (email) Skip the paragraph-spacing fix-up for HTML-less paste
- A paste with no HTML on the clipboard at all (paste-and-match-style,
e.g. Shift-paste on macOS) was regressed by moving the fix-up to
paste.beforeCleanup: Froala synthesizes its own HTML from the plain
text before that event fires, already encoding single vs. double line
breaks correctly on its own (one per line, with an extra empty
- A paste with no HTML on the clipboard at all (paste-and-match-style,
e.g. Shift-paste on macOS) was regressed by moving the fix-up to
paste.beforeCleanup: Froala synthesizes its own HTML from the plain
text before that event fires, already encoding single vs. double line
breaks correctly on its own (one
at every block boundary doubled up breaks that were already correct -- most visibly between a signature's name and its line above.
Gate the fix-up on whether the clipboard actually had HTML (checked in paste.before, since paste.beforeCleanup only receives the html string): with no HTML present, this is a no-op and the previously correct native plain-text handling is left untouched.
- (email) Always show quick filters in supervisor mode
- EmailButtons was hidden whenever the widget was pinned to a specific queue, a leftover from when its only presets were agent-scoped (My, Drafts, Sent) and made no sense without a "me". It has since gained a queueId prop and queue-relevant presets (Received, Assigned, Closed, Failed), and every preset's query is already scoped by queueId, so hiding the whole row for a configured queue was stale and left supervisors with no quick filters at all in that setup.
Also swap the supervisor toolbar's FilterTrigger for the pill-style AdvancedFilterTrigger the agent toolbar moved to, so both views match. FilterTrigger has no remaining callers and is removed.
- Preserve typed text in interaction note when interaction updates
- The Edit interaction modal's FinalForm recomputes initialValues from Redux whenever the underlying interaction record changes (e.g. via a crm.interaction.update websocket event or delayed CDR metadata for a call). Without keepDirtyOnReinitialize, final-form resets all field values to the new initialValues on every such change, silently wiping whatever the user had just typed into the Note field.
Mirrors the existing keepDirtyOnReinitialize usage in ApplicationSettings/index.tsx for the same class of bug.
- Clear oxlint errors in files touched by the dispatch typing
- The previous commit touched files that already carried oxlint errors, so lint:changed failed on it. This clears all 28 of them.
One is a real bug: CallHistory/Item.tsx tested ourNumbers.contains( srcNumber) twice for internal calls, so the "Internal incoming" branch could never run and incoming internal calls showed as plain "Internal". The second check now looks at dstNumber.
The rest do not change behaviour:
- the @ts-ignore lines in Lookups/SpreadSheet.tsx are dropped rather than turned into @ts-expect-error, because handsontable's types do not resolve at all under the current module resolution, so there is no error for them to expect
- SoftPhone's window.sp debugging handle uses @ts-expect-error instead
- cloneWidget's onSubmit returns Promise.resolve instead of being an async function without await, as FinalFormConfirmData requires a promise
- {} constraints become object, any[] in appSelect becomes never[]
- NoteEdit destructures mutate so useCallback does not depend on the unstable mutation object
- a missing key, two regexes without the u flag, unused imports and variables, an empty props interface, a useless fragment and an else after return
- Fix lint issues
- (email) Show a default name for unnamed attachments
- An attachment with no name and a non-text/html mime silently vanished from the read-only list instead of rendering -- the falsy check that used to catch this was replaced by a name ?? ... chain that only fires on null/undefined, not on an empty-string name. Fall back to a generated "Unnamed attachment {id}" label instead of dropping the row.
🚜 Refactor
- (code) Remove dead state and event types left in ucs.ts
accountwas set in initialState but never updated by any reducer case and never read anywhere in the app -- the account-code feature uses a separate ucsMirror slice, not this one. The EVENT_EXTENSION_* discriminated union members (IEventExtensionDelete/Insert) were leftover from the earlier move of transformation extensions off this reducer: nothing dispatches those Redux action types anymore since useExtensionsQuery subscribes to the raw websocket topics directly.
Removing both also fixes three isolatedModules re-export errors in
ucsInterfaces/index.ts, since the dead Account/EventExtensionDelete/
EventExtensionInsert type-only re-exports were never marked as
export type.
- (queue) Use OffsetTimer and stop treating answered as optional
- Caller.tsx added its own clock-offset correction on top of values that callers already offset-adjusted, and typed answered as an optional number even though every caller already treats a missing value as null, not undefined. Both were workarounds for the same gap: nothing matched the shape the data actually has.
Switching to OffsetTimer (already used by Dial.tsx for the same purpose) lets Caller.tsx take the raw, un-adjusted timestamps and apply the offset itself, so the call site no longer needs to. That also removes the only reason the call site had to convert answered's null into undefined, so the prop can be typed as the required number | null it always was.
- (code) Move queueCallers off immutable and type ucs as a record
- queueCallers was the last inbound-queue-caller state still stored as
an Immutable Map<string, List<Map<string, Caller>>>, which is why
Caller.connected was mistyped as string and Caller.answered as a
non-nullable number despite every consumer checking it against null
-- Immutable's Map.get() never caught either mismatch. It's now a
plain Record<number, Array
>, with the two field types fixed to match what the data actually is, and StoreConfig's ucs field retyped as ImmutableRecord so reading it back out (useAppSelector(({ ucs }) => ucs.get(...))) gets a real return type instead of unknown.
- queueCallers was the last inbound-queue-caller state still stored as
an Immutable Map<string, List<Map<string, Caller>>>, which is why
Caller.connected was mistyped as string and Caller.answered as a
non-nullable number despite every consumer checking it against null
-- Immutable's Map.get() never caught either mismatch. It's now a
plain Record<number, Array
PROVISION_SUCCESS needed a real fix along the way: Immutable's .merge() runs any plain-object value through fromJS() before merging, so it was silently re-wrapping the new plain queueCallers back into an Immutable Map on every provisioning event, which crashed LongestWaitingCaller the moment it tried to read a caller's answered field off what was actually a Map. .set() doesn't do that conversion, so PROVISION_SUCCESS and EVENT_QUEUE_STATS now apply queueCallers via .set() instead of folding it into the merge()'d update object.
Queue/View.tsx and LongestWaitingCaller.tsx move to plain array operations to match, and their non-trivial logic (the caller sort order, the earliest-waiting-caller computation) is pulled out into compare-callers.ts and select-earliest-connected.ts so it can be unit tested without rendering either component.
ImmutableRecord's stricter typing on ucs.get() surfaces a handful of pre-existing type errors elsewhere (AuxButton.tsx, Status/index.tsx, AgentSelector.tsx, replaceVariablesInContent.ts, Plugin.tsx) that were previously hidden behind unknown. Left alone for now -- fixing them is unrelated cleanup, not a regression from this change.
- (ui) Move interactive popups onto the lib/ui popover
- Replace the Semantic popup with the Base UI popover wherever the panel is something to interact with or read at length rather than a hover label: the tab-width and font-size slider previews, the queue retry table, the wake-up throttling warning in both top menus, the director menu in the bottom bar, the colour pickers in agent profiles and general widget settings, and the softphone error panel. Tooltips stay on the Semantic popup until lib/ui has a tooltip of its own.
Panels holding a Semantic dropdown or search are left for later: the popover content scrolls, which clips their absolutely positioned menus. That covers the team status control, the softphone and UPhone menus, the agent button, the table filter and the email person editor.
PopoverContent changes along the way. The fixed max-w-sm/max-h-112 caps came out at 336x392px under Semantic's 14px rem and put a scrollbar on the 350px tab-width preview; the panel now sizes to its content and is bounded only by Base UI's --available-width/--available-height. The positioner also goes fixed, as SelectContent already is: absolutely positioned, the panel clipped against the app root's overflow: hidden whenever the trigger sat in a Semantic modal near the bottom edge, which the dev toolbelt makes easy to hit by shortening the root by 35px.
Semantic content leans on .ui.popup rules that no longer apply. The font size preview grid is padded, since .ui.popup > .ui.grid used to cancel the grid's negative margins and without it the panel overflows both ways. The softphone error panel carries its own font size and float instead of size="huge" and Popup.Header, and its onMount becomes an effect on the shown state so the top menu still disables the softphone menu while the error is up.
- (supervisor-control) Move the team status control onto lib/ui
- The multi-agent status change panel opens from the Agent header of both the supervisor control table and the agent stats table. Both now use the lib/ui popover instead of the Semantic popup, and the team, from and to dropdowns inside it are lib/ui selects instead of Semantic dropdowns.
The selects are what make the popover usable here. A Semantic dropdown draws its menu absolutely positioned inside its own markup, so the popover's scrolling panel clipped it to the few pixels below the trigger and scrolled the whole form to reach the rest; it also opened on focus, which the popover hands to the first field as it opens. The select portals its options out of the panel and does not open on focus, so the panel keeps its default overflow and focus handling.
Both call sites move together because the portaled options sit outside a Semantic popup too, which takes a click on one for an outside click and closes. A popover test pins the opposite for our own panel: choosing an option in a nested select leaves the popover open.
The triggers take a 14em minimum width, the Semantic selection dropdown's, so the panel keeps its size.
- (uphone) Move the softphone menu onto lib/ui
- The headphones menu in the top bar -- the UPhone item and the softphone item in both the modern and the legacy menu -- opens on the lib/ui popover instead of the Semantic popup, and the device pickers in its Audio tab are lib/ui selects instead of Semantic dropdowns.
As with the team status control, the selects are what make the popover work: a Semantic dropdown menu is drawn inside the scrolling panel and clipped by it, while the select portals its options out. All three menus move together because they share SoftPhoneSettingsInput, and portaled options would read as an outside click to a Semantic popup and close it.
SoftPhoneSettingsInput hands its callers the chosen value instead of Semantic's (event, data) pair, so the four handlers lose the typeof guards that only existed for DropdownProps. A value that is not among the options -- a saved device that is unplugged, or any device while the list is still loading -- is passed to the select as null: Base UI would otherwise print the raw value, which for a device is its id hash, where the Semantic dropdown showed nothing.
The menus keep focus on the trigger when they open. Base UI would move it to the first button in the panel, which on the Device tab is "Use here", so an Enter straight after opening would reroute calls. The softphone item still refuses to open while its error panel is up, now through disabled on the trigger in place of openOnTriggerClick.
- (email) Use lucide-react icons for attachment row actions
- Swap the download, remove and loading-spinner icons in the attachment row from semantic-ui-react to lucide-react, matching the icon set already used elsewhere in this widget (Email/TemplateEditor.tsx) and for delete actions across the app (Tabs, WidgetBoard, lib/ui/button). Remove is now a trash can (Trash2) instead of Semantic's generic cross/remove glyph, which reads more clearly as "delete" at a glance.
LucideProps has no title prop (unlike Semantic's Icon), so the remove icon's tooltip comes from a wrapping span instead, the same way the download icon already gets its tooltip from the around it.
- (ui) Group the tooltip class strings with cx
- The popup and arrow classes were long concatenated strings that were hard to scan. List them one per line through cx, grouped into layout, surface, densities, open and close, and one block per arrow side, the way the popover and field styles already are. The classes themselves are unchanged.
- (store) Type dispatch so it accepts thunks and interface actions
- Components called plain useDispatch(), which is typed for UnknownAction only. Every thunk dispatched from a component and every action described by an interface (INotifyRequestAction and friends, which lack an index signature) was a type error, and the thunks themselves took untyped dispatch and getState parameters. That was about 220 of the errors outside tests.
storeUtils now exports AppDispatch and AppThunk over the plain Action type, useAppDispatch alongside useAppSelector, and a useAppStore whose dispatch knows thunks. useAppDispatch is an alias rather than useDispatch.withTypes() because tests mock react-redux and withTypes does not exist on the mock at import time.
Components that failed switch to useAppDispatch; the rest keep useDispatch for now. Thunk creators annotate their parameters rather than their return type, so the dispatch result keeps being inferred for callers that chain on it.
ConfirmData becomes a union: either an action or a function returning an action or thunk, or any callback paired with a dispatcher that swallows its result, which is how FormEditor, AgentProfiles and the checklist use it. ConfirmModalContent now checks data.dispatcher before calling dispatch() so the union narrows; behaviour is unchanged.
Typing getState surfaced a missing type on AlterAllQueueRequest and a
missing fullText on CallHistoryData, both fixed here. The event thunk
reads the user id with ?? null since the typed getIn rejects a null
default.
Four put(thunk) calls in crm.saga and softPhone.saga remain, since redux-saga's put does not accept thunks at the type level.
- (email) Replace hideInlineAttachments flag with a disclosure
- Inline attachments (embedded body images) are now always listed, tucked
behind a
disclosure so they stay out of the way without needing a per-deployment config flag to hide them -- the "Download all" button already gives a clean way to grab everything or just the ordinary attachments, so the flag was only adding a second, redundant way to reach the same distinction. The flag is dropped from the feature-flag schema since nothing reads it anymore.
/
- Inline attachments (embedded body images) are now always listed, tucked
behind a
⚙️ Miscellaneous Tasks
- Add /email route to rspack proxy
- (test) Typecheck tests against the vitest and jest-dom globals
- vitest.config runs with globals: true and vitest.setup.ts registers the jest-dom matchers, but tsconfig.json knew about neither: the setup file sits outside src, so tsc never loaded its type augmentation. Every test using describe/it/expect or toBeInTheDocument showed up in typecheck as an error, about 250 of them, burying the real type errors in tests.
List both in compilerOptions.types, as the Vitest docs recommend. types resolves against typeRoots, so typeRoots goes; it only pointed at node_modules/@types and at src/@types, whose loose .d.ts files are included through src/**/* anyway.
Naming types also stops tsc from pulling in every @types package on its own.
That broke one import in the Diagram widget, which reached into
luxon/src/_util for Valid -- a path @types/luxon does not export and only
resolved as a side effect of the automatic inclusion. Valid is an alias for
true, so Duration
typecheck goes from 3567 to 3309 errors on the same master, 356 to 102 in tests; nothing new appears outside them.
- Satisfy oxlint in the diagram widget
- The previous commit touched src/widgets/Diagram/View.tsx, which puts the whole file under lint:changed. It has imported selectOutboundQueues without using it since before that change, so oxlint failed on the branch. Drop the import; the widget only reads selectActiveOutboundQueues.
[9.43.0] - 2026-09-24
🚀 Features
- (ui) Add a popover primitive to lib/ui
- Ports the shadcn/ui popover built on Base UI. Part names, composition and
data-slotattributes are upstream's; the look is ours, since upstream's lives in acn-popoverstylesheet we do not carry. Upstream ships the parts as one file -- they are one per file here, so a re-port becomes a merge rather than a diff.
- Ports the shadcn/ui popover built on Base UI. Part names, composition and
Three departures are worth knowing about. The content portals into the app root
instead of <body>: that root carries the theme class and data-density, so a
popup mounted on <body> renders light and compact whatever the user picked.
The panel sizes to its content rather than taking upstream's fixed w-72, with
24rem caps on both axes so long content cannot run off the screen. And
PopoverClose is exported as well, because Base UI only traps focus for a modal
popover when one is rendered inside the popup, and a touch screen reader has no
other way out.
lib/ui/button could not be handed to a Base UI render prop: it forwarded
neither a ref nor the props it does not declare. The popup measures its trigger
through that ref and wires the trigger up through the handlers and aria
attributes it clones on, so both now pass through. The change is additive --
every existing call site passes only props Button already knew.
- (email) Add pill advanced-filter trigger to the agent toolbar
- The agent toolbar had the quick-filter pills on one side and the advanced filter behind a Semantic icon button on the other, so two halves of the same control read as unrelated widgets. The new trigger is the same pill as the presets and sits directly beside them.
The supervisor toolbar keeps the legacy Semantic FilterTrigger. It sits in a row of Semantic Button.Groups there, where a lone pill would be the odd one out.
Below a 700px toolbar the trigger drops its label and shows only the icon, so the presets keep the row to themselves for longer before it wraps. That threshold is a container query on the toolbar rather than a media query, because the widget is resized independently of the window.
EmailButtons.module.scss becomes filter-bar.scss and .presetButton becomes .pillButton, since both components now style themselves from it. The five preset filters that do not depend on the agent id move out of the useMemo: they are constant, and the memo never protected the query keys anyway, which React Query hashes structurally.
The pill also loses focus-visible:ring-offset-1. Tailwind defaults --tw-ring-offset-color to #fff and nothing in the project sets it, so a focused pill drew a 1px white band that ignored the palette entirely.
"Advanced" gets a message context. Czech needs the plural "Pokročilé" to agree with the filters, not the masculine "Pokročilý" the catalog already holds for the unrelated Advanced label.
- (tabs) Rework the selected, hover and edit states of the tab menu
- The tab menu drew hover and the active tab with the same borders, so the
two were indistinguishable, and the selected state vanished entirely once
the dashboard was unlocked: TabContent wrapped the NavLink in a div for the
drag preview, which put the router's
activeclass on an element the tab styles never matched. The NavLink is now the direct child of the list in both modes, so selected survives unlocking and coexists with hover, input focus and dragging.
- The tab menu drew hover and the active tab with the same borders, so the
two were indistinguishable, and the selected state vanished entirely once
the dashboard was unlocked: TabContent wrapped the NavLink in a div for the
drag preview, which put the router's
Rows are inset from the menu's rail, spaced apart and rounded, so a highlight reads as its own block rather than a band across the menu. That also retires the negative-margin trick the rows used to cover the rail with their own border. Hover is a neutral wash of the ink colour, which darkens in the light theme and lightens in the dark one; selected is a step on the brand-secondary rail plus an accent bar on the row's leading edge, so it reads as clearly stronger than hover. Both come from tokens already in palettes.scss.
In edit mode the semantic Input and its attached buttons are replaced by a quiet input that only grows a border and a focus ring when it is hovered or focused, a lucide Trash2 that uses the negative tokens on hover -- the same danger tint the e-mail filter buttons use -- and a GripVertical handle kept apart from delete so a missed grab does not hit the wrong control. Both icon buttons carry a label and a tooltip, and grabbing the handle no longer navigates. Delete still goes through the existing confirmation modal.
Add tab keeps its button, now prefixed with a plus icon and no longer bold, and its row no longer lights up on hover the way a tab does. Long names are truncated rather than widening the menu.
🐛 Bug Fixes
- (ui) Let the select popup open above a semantic modal
- The new lib/ui/select was unusable inside a Semantic modal: the popup rendered behind the modal, and even once in front it collapsed to an empty 23px strip. Two independent causes, both of which every remaining Semantic dropdown call site would hit as it is migrated.
Base UI's Positioner defaults to positionMethod="absolute". A trigger inside
a Semantic modal sits in a position: fixed ancestor (.ui.page.modals) that
the portaled popup does not share, so the absolute coordinates are computed
against the wrong containing block: --available-height came out as -10px,
Base UI flipped to side="top" and gave the list zero height. Positioning the
popup fixed makes the anchor measurement viewport-relative and the panel
lays out normally (--available-height 323px in the same modal).
The z-index came from b94c637c5 (2023), which raised every .ui.dimmer to 100000 so that a loading overlay in Call History would cover an open react-datetime calendar -- the calendar's own stylesheet pins .rdtPicker at 99999 !important. That overlay has since been replaced twice over and is now a LoadingBar, so nothing needed the bump any more, but it applied to the page modal dimmer too and buried anything portaled next to it. The colour picker in widget settings ran into the same wall four days later (757758f1a) and was answered with zIndex: 10000000000.
Instead the calendar drops below the dimmers it should never cover, and the dimmer lands on the lowest value that still works: above the WaveForm panel, which floats over the top menu at 1001, and below Semantic's 1900 for .ui.popup so portaled popups can cover a modal. A calendar inside a modal is unaffected either way, since .ui.modal establishes its own stacking context.
Tabs/TypeSelectModal is the first Semantic Dropdown converted over. Its
option list grows a label key because that is what Select.Root's items
expects, and items is what makes the trigger show the label instead of the
raw enum value. onValueChange is typed TabType | null -- Base UI models
clearing the selection -- so it needs a narrowing handler rather than the
setState function. selectOnBlur={false} has no counterpart: it worked around
Semantic committing the highlighted option on blur, which Base UI never does.
- (ui) Raise the popover max height to 28rem
max-h-96capped the panel at 24rem, well short of the room a popover actually has below the top menu. That cap is only a rail to stop a tall panel running off the screen, so engaging it at roughly half the available height turned panels that had space to open fully into scrolling ones. 28rem keeps the rail without clipping the panels we have.
- (topmenu) Open the ready channels sooner on hover
- Base UI defaults the trigger's hover
delayto 300ms, which read as lag on a control agents hit constantly -- the panel trailed well behind the pointer that had already arrived. 100ms still guards against opening on a pointer that is only crossing the button on its way somewhere else.
- Base UI defaults the trigger's hover
- (storybook) Load the react-datetime styles in the preview
- The app imports them from src/index.tsx, which the preview does not go through, so the calendar in the date and time picker stories rendered as an unstyled table and stayed open under the input.
- (tabs) Keep the selected tab marked when the dashboard is locked
- A component tab answers to two urls: the readable one the menu links to while locked (/call-history) and the static one edit mode links to (/tab/static/component/5). Every NavLink asked the router whether the current url matched its own link, so clicking a tab while unlocked and then locking left that tab on screen with nothing marking it as the selected one -- ConfigurableTabPage renders the tab from the id in the url, so the content was right and only the menu was wrong. Ordinary widget-board tabs were never affected: both of their urls are the same string.
This predates the menu's restyling. Hover and the active tab used to share one declaration block, so a row under the cursor -- which is where the cursor is right after clicking a tab -- was indistinguishable from the selected one and the missing class never showed. Telling the two states apart is what made it visible.
Deciding it per row also meant a presentational component reaching into the
router, twice for every tab. The menu now resolves it once from useLocation and
hands each row an active prop, which leaves the rule itself a plain predicate
over a pathname that can be tested without rendering anything.
- (tabs) Lighten the selected and hover fills of the tab menu
- The selected row read too dark against the white surface, and both fills were derived at the call site -- hover as a 7% wash of the ink colour, selected through the oklch l/c ramp off --tile-widget-initial-color. That minted shades living nowhere in the palette, so every adjustment meant another arbitrary expression in index.scss rather than a value someone could look up.
Three named greys now sit in palettes.scss instead, written in oklch so each theme is a single lightness column: 0.97 / 0.955 / 0.94 in light, 0.31 / 0.35 / 0.39 in dark. Chroma is zero, so the rows stay neutral rather than picking up the cool cast the surrounding surfaces carry. index.scss only names them.
The accent bar on the selected row goes with it -- the fill alone marks the selection now.
- (tabs) Wrap long tab names instead of truncating them
- A tab name that did not fit was cut off with an ellipsis, which hides exactly the part that tells two similar tabs apart -- a person's surname, a task number, the tail of a subject line. The menu has vertical room to spare, so the name now wraps onto further lines and the row grows.
overflow-wrap is anywhere rather than break-word: both break a word that does not fit, but break-word leaves min-content at the width of the longest word, so a single unbroken token -- a URL, a generated template name -- would still push the row out and unbalance the menu width.
The temp tab needed min-width: 0 on the name span as well. A flex item will not shrink below its min-content width by default, so without it the name would refuse to wrap and shove the remove button instead. The remove button and drag handle already carry flex: 0 0 auto, so they hold their size either way.
- (tabs) Key tab name badges by position, not by state path
- A tab name may reference the same state path twice --
%queues.47.new% %queues.47.new%is a reasonable thing to write -- and the badges were keyed by that path, so the two siblings shared a React key. Unlocking the dashboard rebuilds the row's children in place, and with a duplicate key React failed to remove one of them: a stale badge stayed in the dom, in front of the name input, and survived locking again. Only a reload cleared it. React had been saying so all along in the console.
- A tab name may reference the same state path twice --
This never fired before the menu restyling. The unlocked row used to be a div wrapping the NavLink while the locked one was the NavLink itself, so switching modes changed the root element type and React discarded the whole subtree. Keeping the NavLink across both modes -- which is what lets the selected row stay marked while unlocked -- is what turned the latent duplicate key into a visible one.
The badges are derived from the positions of name.split('%'), so the index is what actually identifies them.
Also pins the row height to one line plus its padding, so unlocking no longer resizes the row by the half pixel between the text line box and the taller input, and drops the row's own hover and selected fills while unlocked -- editing the menu is not navigating it, and the input and buttons carry their own hover.
- (tabs) Pin the add tab button to the foot of the unlocked menu
- With enough tabs the menu scrolls, and the add button scrolled away with the list -- adding a tab meant scrolling to the bottom to find the control first. It is the one action in edit mode that does not belong to a particular row, so it should stay reachable wherever the list happens to be.
Sticky rather than a second scroll container: the button is the last child of the list, so it only needs to stop leaving the scrollport, and that keeps it inside the rail and aligned with the rows above it. Moving it out of .tabs would have meant rebuilding the left border and the horizontal padding around it to land in the same place.
The background has to be opaque now that rows pass behind it, and the flex gap above it is folded into its padding -- the gap sits outside the element, so without that a sliver of the scrolling row would show between the last row and the button.
- (tabs) Keep the row box when the tab menu unlocks
- The unlocked row dropped its bottom and side padding and let the name input drive its height, so the name landed about six pixels lower than the label it replaced and every row below it drifted by another one and a half. Unlocking the dashboard read as the whole menu sliding down.
Hold the locked row's box instead: one line of text plus the padding, with the padding symmetric again. The input and the icon buttons are taller than one line and now overflow into that padding rather than growing the row -- the overflow is clipped at the row edge, so the input's focus border and the buttons' hover chips still show in full. The add tab button is not a row and opts out of the fixed height.
Side padding stays off, so the input and the add tab button below it keep sharing one edge.
- (task) Keep the metadata checkbox from blocking task save
- Touching a checkbox in queue metadata made the task impossible to save. FinalSemanticCheckbox writes a boolean into metadata, but the form parser declared metadata as a record of strings, so safeParse failed and onSubmit only logged a warning -- FORM_ERROR is never rendered, so the Save button looked dead. An untouched checkbox left the key absent, which is why the form saved as long as nobody used it.
The metadata schema is now derived from the queue's own metadata definition instead of being one flat record type, so each key is parsed as the type the queue declares for it. Adding a variant to richMetadataParser now fails to compile until every consumer decides what value the new type carries, which is the mistake this bug was. Values of keys the queue no longer defines pass through untouched, so saving a task does not drop data from an older queue configuration, and a checkbox held as a string is normalized rather than rejected.
Task data is widened to string | number | boolean along the whole path, including the crm.tasks.get response parser -- that one matters, because it runs through a hard parse and a mismatch takes down the whole task detail, not just the field. The backend declares the value as Mixed, so a record of strings was our assumption rather than its contract.
Reading the queue config now narrows on the task queue type instead of testing for individual keys. The email queue carries a different structure under the same metadata key, and unifying the two erased the discriminated union that the derived schema needs.
- (config) Type onConnect's version argument
- onConnect was typed as a niladic callback, but App.tsx has always invoked it with process.env.VERSION to preserve the event order of older, pre-deprecation integrations. The mismatch surfaced as a TS2554 arity error at the call site once safeCall started inferring expected arguments from the callback's declared type.
Type the callback as (version: string) => void, matching how it is actually called, and wrap it with z.function().implement like the other onX config callbacks so the argument is validated at runtime too.
🚜 Refactor
- (theme) Expose the popup shadow and table border as tokens
- The popover landed with its shadow written out twice as a raw
rgb(0 0 0/12%)andrgb(0 0 0/45%), the second behind a.darkvariant, and with its border colour reached throughborder-(--table-border-color). Neither belongs in a component: the palette is where a colour is decided, and a class that names one directly cannot be changed from there.
- The popover landed with its shadow written out twice as a raw
--popup-box-shadow already existed and is what Semantic's own .ui.popup
draws with, so it is both the right value and one the palette already switches
per theme. Registering it as --shadow-popup therefore replaces both classes
and lets the dark override go entirely -- what stays under .dark is only the
lighter surface. The visible consequence is that the popover now casts the same
shadow as every other popup in the app instead of a hand-rolled one; the two
sat side by side and did not match.
Both tokens go in @theme inline rather than a plain @theme, for the reason
the comment above the block already gives: the palette is defined on
.elementRoot, not :root, so the utility has to inline the var reference or
it resolves against nothing.
- (topmenu) Open the ready channels in our own popover
- First of the header popups off Semantic.
hoverablewith the defaulton="hover"becomesopenOnHover, andposition="bottom left"becomesalign="start"on a bottom side, which is already the default.StatusOptionneeded nothing: it forwards its ref for exactly this, as its doc comment says.
- First of the header popups off Semantic.
closeDelay is the one part that is not a straight translation. Base UI defaults
it to 0 and the panel sits sideOffset away from the option, so the pointer
leaves the trigger before it reaches the panel and the panel closes underneath
it -- the channels would have been impossible to get to with the mouse.
The Semantic Grid inside had to go with it. Its margin: -1rem put the grid
14px outside the panel's content box on each side, which made the panel overflow
by a few pixels in both directions and, since the panel scrolls what does not
fit, drew a horizontal and a vertical scrollbar on a panel with two rows in it.
A flex column has no negative margins, so the panel now sizes to its content
rather than being held open at the grid's full width.
Clicking Ready straight from hover still leaves the panel up, as it did before; Base UI only toggles it shut on a click that follows the pointer having been inside the panel.
- (ui) Draw the select panel with the palette popup shadow
- The select was the last of the two panels still naming its own shadow colours:
rgb(0 0 0 / 12%)in light andrgb(0 0 0 / 45%)behind a.darkoverride. The popover moved to--popup-box-shadowearlier, so the two sat next to each other in the same shell drawing visibly different drops -- the select's heavier and pushed further down.
- The select was the last of the two panels still naming its own shadow colours:
--popup-box-shadow is what Semantic's own .ui.popup draws with and the
palette already switches it per theme, so taking it here both settles the
difference and lets the dark box-shadow line go entirely; what stays under
.dark is only the lighter surface. The comment there claimed the stronger drop
did the separating, which was no longer true once the shadow came from the
palette.
Checked in the browser rather than by eye: both panels now compute the same painted shadow layers in both themes.
- (tabs) Drop the leftover accent indicator from the tab rows
- The ::before rule was the accent bar that used to mark the selected tab. Lightening the fills took the accent override away, so the rule was left painting a 3px transparent strip on every row -- dead weight that reads as a live feature to anyone scanning the block, and one the next person would likely restore by accident.
The comment on the unlocked rows went with it: it explained the side gutter as clearance for an indicator that no longer exists, when what the gutter actually keeps clear is the drag handle.
- (tabs) Clear the oxlint errors in the tab row
- The colour suffix parsing sat five blocks deep inside the name memo, one past the max-depth limit, so lint failed on the file as a whole and hid anything else it had to say. Lifting it into splitColor keeps the loop body at three levels and lets the part key stay a const, since the helper returns the stripped key rather than reassigning it in place.
The other two are mechanical: the integer test regex gains the unicode flag, and the forwardRef component gains a displayName so it is not anonymous in the React tree.
- Move transformation extensions off the immutable ucs state
- Immutable.js v3's weak type definitions are a major source of the type errors accumulated during the JS-to-TS migration, and ucs.ts is the largest single contributor. transformations was a good first slice to extract: it had exactly one consumer (SettingsModifier) and two isolated reducer cases with no cross-references to any other part of the state tree.
The extension list needed by the transformation dropdown is already fetched by DispatchingRooms via config.extensions.list, so that query is pulled into a shared ucs_client/extensions-query.ts instead of duplicating it, with useExtensionsQuery wrapping live updates via the extension.insert/update/delete websocket events (invalidate only -- these events are rare enough that a full refetch per event is fine, no optimistic patching needed). Both DispatchingRooms and SettingsModifier now go through the same hook, so any future consumer of this data gets live updates for free instead of relying on each call site remembering to subscribe.
Also adds ucs_client/querySync.ts, a dispatch point for cache patches that must run exactly once regardless of how many components read the query, wired into StoreProvider's single websocket ingestion point. It has no cases yet -- nothing in this change needs it, since transformations only needs plain invalidation -- but it is the intended home for any future high-frequency event that needs to patch a query's cache instead of refetching it.
The Transformation interface became dead once ucs.ts stopped storing it, so it is removed along with its re-export.
- (provisioning) Drop the pinned extension subscription
- extension.insert/update/delete no longer need to be subscribed for the whole session. Connector.subscribe() pins the refcount to +Infinity, so a topic added here can never be torn down by refCountUnsubscribe even once nothing needs it anymore.
Now that ucs.ts no longer consumes these events, the only remaining consumers are useExtensionsQuery (ref-counted, active only while DispatchingRooms or SettingsModifier are mounted) and UcsStateSync (always mounted, so extension.update was already covered independently of this list). Removing the pinned entry lets these three topics correctly follow whether anything is actually displaying extension data instead of always being subscribed.
📚 Documentation
- (ui) Show the popover opening on hover
openOnHoveris Base UI's own trigger prop and our trigger spreads what it does not name, so hover already worked and nothing in the component changed. The story exists for the part that is not discoverable from the prop list.
closeDelay defaults to 0, and the panel sits sideOffset away from its
trigger. The pointer therefore leaves the trigger before it reaches the panel
and the panel closes underneath it -- a hover popover written the obvious way
cannot be moved into at all. The story passes 200ms and says why, so the next
call site does not have to find this by trying it.
It also keeps the reachability caveat next to the code: hover opens the panel but click and the keyboard still do too, which is what stops the content becoming unreachable for anyone on a touch screen, where hover does not exist.
- (agents) Add a skill for syncing server/websocket data
- Document the pattern this session settled on for keeping data live via TanStack Query instead of the legacy Immutable.js reducers: when to invalidate vs. patch a query, why patches must run exactly once (querySync.ts, wired into StoreProvider's single event ingestion point) while invalidation is safe from any number of components, and the gotchas that actually bit during the transformations migration (websocket topic strings vs. Redux action-type constants being different namespaces, Connector.subscribe's permanent refcount pin).
Written as a skill rather than a plain doc so future sessions get it loaded automatically when the task matches, the way display-density already works in this repo.
🎨 Styling
- (ui) Drop the popover title margin comment
- The heading reset is legible from the class list itself, and the note about Semantic styling bare headings was the kind of restatement that goes stale once Semantic is gone.
- (ui) Drop the popover surface comments
- Both restated their own class rather than explaining it -- the shadow token already names what it is, and the dark override reads off its own selector. The Semantic reference in the first would have gone stale with the dependency we are removing anyway.
- (tabs) Put the menu item comment next to the class it explains
- The note about
itemmaking the link render as aMenu.Itemsat on theclassNameprop of the locked branch's NavLink, two returns away from the string it is about, and so read as if it explained that one call site -- while the unlocked branch composes the same class name and needs the sameitemfor the same reason. Moving it onto the template literal keeps it with the class it describes and covers both users.
- The note about
🧪 Testing
- (metadata) Clarify catchall intent in values-parser tests
- The "removed from queue" and button tests only asserted success without saying why: createMetadataValuesParser keeps values with no prescription (an unknown key, or a button, which has no value type of its own) instead of stripping them, via the schema's catchall. Name that intent in the test descriptions and in a comment on the catchall itself, and assert the kept value directly instead of just a passing safeParse.
⚙️ Miscellaneous Tasks
- (storybook) Add stories for the button primitives
- The button directory ships three components and had no stories, so the colour variants, the transparent modifier and the gapped icon layout were only visible by finding a call site.
Both Button and IconButton disable themselves when no onClick is passed, which is not visible from the props; the Disabled stories show it next to an explicitly disabled button.
- (storybook) Add stories for InlineBadge
- Covers the Semantic palette, the inline backgroundColor override the tab counters rely on, and the em sizing that makes the badge follow its surrounding text.
- (storybook) Add stories for Bubble
- Shows both sides of a conversation, the alignment the bubble does for itself, and how preserved whitespace and word breaking hold up on pasted content.
- (storybook) Add stories for the dialog modals
- Every story opens from a trigger rather than from the open arg: the dialog is a native one driven by showModal(), so an open-by-default story would cover the docs page it sits on.
Covers the full screen and transparent variants, the swallowed Esc and the unmount-on-close behaviour, plus the header on its own with and without its close button.
- (storybook) Add stories for FancyRadio
- The group has no call sites left in the app, so the stories are now the only place its colour handling, read-only mode and empty skeleton can be seen.
- (storybook) Add stories for the input pickers
- The date and time pickers carry a tagged value -- a luxon DateTime once the text parses, the raw string while it does not, null when empty -- and the stories print that value next to the input so the states are visible rather than inferred from the type.
Also covers the raw field that keeps a half-typed date from reformatting itself, the duration input's hh:mm parsing and the clearable input in both its controlled and uncontrolled form.
- (storybook) Add stories for the final-form pickers
- Each story puts the binding inside a real Form so the touched-then-error behaviour is the one the app gets, rather than a label rendered by hand.
The time picker takes its format through a render prop: final-form owns the format prop name on Field for its own value formatter, so a field that wants seconds cannot pass it as component.
- (storybook) Add stories for the item list
- The row takes its padding, hover and spacing from the list around it, so the Item stories render inside an Items decorator rather than on their own, where the component looks unstyled.
- (storybook) Add stories for KatexFormula
- Uses the talk time ratio the supervisor overview puts in its column popups, in both the block and the inline form, plus broken TeX to show that rendering degrades to red source instead of throwing.
- (storybook) Add stories for the radio group
- Covers the stacked and inline layouts and node labels, and pairs two groups to show that name is what separates them -- sharing one name lets a pick in the second clear the first.
- (storybook) Add stories for TextLabel
- Shows the label wired to a control through htmlFor and its em sizing, which is what lets it sit in a Semantic form without looking foreign.
- (storybook) Add stories for TextAreaAutosize
- Covers the growing behaviour with minRows and maxRows, the point where it starts scrolling instead, and the disabled state.
- Satisfy oxlint in the storybook preview
- The api mock's rest parameter is unused; prefixing it keeps lint:changed clean now that the preview is part of the branch's diff.
[9.42.0] - 2026-09-21
🚀 Features
- (email) Add clear formatting, font, and color buttons to editor
- Agents had no way to strip formatting or change the font/color of a reply from the toolbar. Add clearFormatting, fontFamily, fontSize and textColor to the button list, grouped with the other text-styling buttons.
fontFamily also needed registering: this project loads Froala plugins one file at a time in froalaPluginRegister.js rather than the pkgd bundle, and font_family.min was never added there, so the button would have rendered nothing. colors and font_size were already registered by earlier work.
- (email) Add a button to unify inconsistent text formatting
- Replies built from pasted content or several rounds of font/size picking end up with a patchwork of typefaces and sizes. Add "Unify Formatting" next to Clear Formatting: it strips inline font-family everywhere and inline font-size outside headings, leaving bold, italic, underline, color and heading tags untouched.
Neither property is reapplied. With no per-element override left, text falls back to the editor's own global font (the queue's configured brand style, via the same mailStyle rule the live preview already uses), which is exactly the font a "make this consistent" button should produce. Only heading elements themselves keep their own size, since that is what makes them headings -- a styled span nested inside one still gets unified. Deprecated tags from Outlook/Word paste are stripped the same way, since they carry the same kind of override outside a style attribute.
Acts on the current selection when there is one. Otherwise it sweeps the whole body, since unifying only part of a reply isn't really what the button is for. Content inside a
-- where the backend puts the quoted original message being replied to -- is always left alone, selection or not: a "make my email consistent" pass has no business rewriting text the agent didn't write, and a plain Ctrl+A before clicking the button is a far more likely selection than one that deliberately targets the quoted text, so selection can't be trusted to signal intent to unify the quote.🐛 Bug Fixes
- (email) Restore list styling in froala editor content
- 9bb63c6d6 gutted froala.scss to remove dark-mode toolbar and popup overrides that made popups transparent, but took the unrelated .fr-element content reset block down with it. That block reverted Tailwind's preflight, which strips list-style and padding from ul/ol globally, so bullets and numbers stopped rendering in the froala editor and any e-mail using them. Restore the reset (list-style, margins, heading sizes, link color, table borders, box-sizing) without bringing back the toolbar/popup styling that caused the original regression.
Move the reset into a shared content-reset mixin and apply it under the Email editor's own .froala class rather than the global froala.scss import: the Notes widget renders the same FroalaEditor/FroalaEditorView markup and never had this reset, so sharing the mixin (rather than duplicating it) keeps a follow-up fix for Notes from drifting out of sync with this one.
FroalaEditorView (used for the read-only, disabled state of this same editor) renders a bare
, not the .fr-wrapper .fr-element structure the editable instance has, and it accepts no class of its own to hook a selector onto. The mixin takes its root selector as a parameter so it can target .fr-view too, applied under .froalaEditorArea -- the one element both the editable and read-only variants render inside.
- (email) Keep froala dropdowns above the sticky action bar
- The email editor's Cancel/Save/Send bar is position: sticky with z-index: 5 so it stays visible while the reply body scrolls. Froala's own paragraph style, paragraph format, align and line height dropdowns render at z-index: 4 (.fr-dropdown-menu in its bundled CSS), which sat below the action bar's 5, so any dropdown that reached far enough down rendered underneath the buttons instead of over them.
Lower the action bar to z-index: 2, below the 4 Froala's dropdown menus use, but still above the plain scrolled editor body.
- (note) Restore list, heading and table styling in note content
- Tailwind's preflight strips list-style, margins, border and heading sizing from every element in the app, including Froala editor content, but Note's own froala.scss never reverted it back like Email's fix does. Notes exposes the same formatOL/formatUL and insertTable buttons Email does, so a bulleted list, heading or table typed into a note rendered without markers, native heading sizes or borders.
Apply the content-reset mixin Email already uses under Note's own .froala class for the editable instance, and under .contentWrapper -- scoped to .fr-view instead, the class FroalaEditorView actually renders -- for the read-only view shown once a note is saved.
- (filter) Restore list, heading and table styling in filter action editor
- Tailwind's preflight strips list-style, margins, border and heading sizing from every element in the app, Froala editor content included. FroalaWrapper.jsx renders a third, previously unfixed Froala instance (the rich-text action value in a mail filter rule) with the same formatOL/formatUL/insertTable buttons Email and Notes expose, so it was equally susceptible.
Apply the content-reset mixin under this editor's own .box class, the same way Email and Notes already do.
- (email) Stop overwriting drafts on unmount for non-owners and unloaded bodies
- editorEmailId is set to the thread's existing reply_id for every agent who opens that email, not just the reply's owner -- the owner check (ownReply) only decided which component rendered (editable vs read-only FroalaEditorView), never whether componentWillUnmount was allowed to save. So a supervisor or colleague who only viewed someone else's in-progress draft and then closed the tab would re-save whatever snapshot of that draft they had in local state, clobbering any changes the real owner made concurrently, via an unconditional email.body.set RPC with no conflict check.
Worse, that local snapshot starts as '' in the constructor and is only replaced once the async email.body.get fetch resolves and getDerivedStateFromProps copies it over. Unmounting before that fetch resolves -- plausible on a slow connection, or just opening and quickly navigating away -- saved that empty string over the real draft, blanking it. This happened even for the reply's own owner, since it's a fetch-vs-unmount race independent of ownReply.
Only save on unmount when the viewer owns the reply and the body was actually loaded (editorBody is no longer null). Every other caller of handleSave -- the Save button, Send, and the debounced autosave while typing -- is already reachable only once both of those are true, so they need no extra guard.
[9.41.0] - 2026-09-18
🚀 Features
- (device) Trace audio device resolution and playback, warn on stale device
- Ring-not-working reports were hard to diagnose because nothing recorded whether a saved output/input/ring device label actually matched a device the browser currently offers, or whether the resulting play() attempt on that device succeeded.
Add OpenTelemetry spans around both steps: device.resolve in DeviceConfigurationManager tags whether a persisted label was found among the available options, and device.play in UPhoneInternal wraps the ring, bridge-ring and auto-answer play() calls with the resolved device id. A "not found" resolution stays span status UNSET rather than ERROR, since falling back to the default device is expected behaviour, not a failed operation; app.device.found carries that signal for anyone filtering or alerting on it instead.
Also surface the same "not found" condition to the user: when the device saved in SoftPhoneSettings is not amongst the currently available options, show the stored label in a small warning under the affected dropdown, styled from the palette rather than through Semantic's Message component.
🐛 Bug Fixes
- (email) Allow replying to emails assigned to another agent
- Team members need to cover for a colleague who is on vacation or sick and unable to answer their assigned customer emails. The disabled prop on ReplyButtons blocked reply/forward for any agent other than the assignee, with no way around it short of a supervisor reassigning the email first. Drop the ownership check so any agent can reply or forward regardless of assignment; a reply already in progress still hides the buttons for everyone via email.reply_id, so this does not reintroduce double replies.
[9.40.0] - 2026-09-17
🚀 Features
- (supervisor) Show talktime formulas in AgentStats header tooltips
- Supervisors asked how the relative talktime and talktime-to-logged-time percentages in the agent stats table are computed. Render the actual formula with KaTeX inside the existing header tooltips, using localized labels for each term so the formula reads correctly in every language.
🐛 Bug Fixes
- (widget-core) Guard against null substitution values in color path
- useAppSelector returns identifier/spUsername as string | null, so passing them straight into String.replace no longer type-checks. Fall back to an empty string so a missing identifier or softphone username just drops the placeholder instead of failing to compile.
- (dispatch-bridge) Don't mark a lone caller's own bridge as attended
- isAttended treated any agent participant as proof the bridge was being attended, so a bridge with just its creator ringing out (an internal call not yet joined by anyone) showed up as attended even though no one else was on it. A later fix tried excluding participants that matched the bridge's creator, but that broke the common case of a creator agent connected to a PSTN customer, since the customer is never an "agent" participant and so could never satisfy that check.
Key off participant count instead: an agent participant only counts as attending once someone else has joined the bridge. Add unit tests for isAttended, deriveState and splitBridges, which previously had no coverage at all.
- Add missing translation
- Address review feedback on alternative audio profile
- Take the audio profile badge tint from the palette tokens
- The badge fill was tinted with a fixed 93% lightness and a scaled chroma, which stays light in the dark theme. Using the palette's --l-200 and --c-200 steps keeps the tint consistent with the rest of the palette and lets it follow the theme.
- Return null from useActiveAudioProfile when no profile matches
- The review-feedback commit changed ActiveAudioProfile to null | { id, alternativeOutputActive } so that "no profile" is the null case and id is never null inside the object. The hook body and the badge component kept the old shape: the hook still returned an object with a nullable id, which no longer type checks, and AudioProfileTriggerIcon destructured id from a value that can now be null.
Finish that change instead of reverting it. The hook returns null when detectActiveAudioProfileId finds nothing, so the redundant null guard on the alternative-output check goes away, and the badge checks the profile itself before reading id and alternativeOutputActive off it.
🚜 Refactor
- (widget-board) Render the fast widget list from an array
List.mapreturns an immutable Iterable rather than an array, which is not a ReactNode, and the item it hands the callback carried no attribute typing, soitem.get('layout')was rejected for a key the prop type never declared. Converting the list to an array first gives JSX a real array of children and types each item, so the layout access checks out once the prop type declares the key that code already reads.- (widget-core) Type the widget wrapper item with ImmutableRecord
- Gettable declares get and has as properties holding function types, so T sits in a contravariant position and a map carrying more attributes than T is rejected: every caller has to name exactly the attribute set the callee names. ImmutableRecord declares get as a method instead, which puts it under method bivariance and lets a wider map satisfy a narrower record, while the subset direction still fails. A Gettable value satisfies it too, since the bivariance comes from the target member, so call sites need no conversion.
has is left out. Constrained to keyof T it can only ask about keys the type already promises are there, so every legal call answers true; asking about a key outside T would need a this-typed predicate that widens the record, which is not worth building while we are migrating off immutable.
WidgetWrapper drops id and layout from the item type along the way. It reads neither, and requiring layout rejected the call in WidgetBoard, whose list does not carry it.
- Read the active audio profile inside AudioProfileTriggerIcon
- Both SoftPhonePopupTrigger and UPhonePopupTrigger called useActiveAudioProfile only to pass the result straight down to the icon. The icon now calls the hook itself, so the triggers no longer duplicate the call and the activeProfile prop is gone.
🧪 Testing
- (dispatch-bridge) Pin the ordering rules in bridge-state
- The suite added with the isAttended fix asserted true things but left several decisions of deriveState and splitBridges unguarded. A lone ringing participant is H rather than E, and a conference with a ringing member is F rather than E, because the count checks run before the state check; nothing pinned either ordering. E was only asserted with the customer ringing, not the agent. Every splitBridges test passed a single bridge, so a broken accumulator in the reduce would still pass.
Add tests for each of those, plus one that passes four bridges and expects all three buckets to be filled. The transfer test now asserts T at one, two and three participants so the T-before-count rule is pinned too.
Drop
creatorfrom isAttended tests where the function never reads it, and rename the ringing-agent test to state the rule it encodes: a bridge where one agent is still ringing another counts as attended. The creator-plus-customer test keepscreatoron purpose, since that is the regression the previous commit fixed.[9.39.0] - 2026-09-16
🚀 Features
- (ui) Replace the status buttons with a StatusSwitcher strip
- The agent status was a Semantic
Button.Groupof four fully saturated, fully bordered buttons, styled throughstyleConstructor.tsand a block of.statusButtonsrules in the global stylesheet. On the dashboard, which is already dense with borders, it read as four boxes inside a fifth.
lib/ui/status-switcherreplaces it: one hairline frame, and the current status carried by a wash of its own colour filling its tile edge to edge plus a dot, with no inner dividers and no padding around the active tile. The label keeps the normal text colour, so contrast holds whatever hue a deployment configured -- and the wash iscolor-mix, not the OKLCH level rail, because these colours are often achromatic and the rail tints them pink.Options are children rather than data because each behaves differently: Ready opens a channel popup on hover, ACW is display-only and so renders as plain text with no button semantics, Not ready carries the reason menu, Offline logs out. The menus themselves stay at the call sites, which need
semantic-portal-attached/Popupto escape the top menu's stacking context.Density comes in as a prop, so the shell no longer sizes these through
.ui.button.statusColorhands "unset" on asundefinedinstead ofgetColor's near-white grey, which the old bordered buttons needed but a dot dissolves into.Also fixes the Status story, which set a
topmenu.offlineButtonkey that does not exist -- the setting islogoutButton, so the Offline option was never rendered in the story.
- (ui) Add a Select built on Base UI
- The app has no dropdown primitive of its own. Form selects are Semantic's
Dropdown, action menus areDropdown.Menu, and the only accessible menu in the repo is hand-rolled inside AuxButton, which is also the single place usingrole="menuitemradio"and has no keyboard navigation at all. Base UI is already a dependency -- lib/ui/switch is built on its Switch -- so a select can have arrow-key navigation, typeahead, focus management and collision-aware positioning without adding a package.Ported from shadcn/ui's Base UI select, keeping its part names, composition and
data-slotattributes so a re-port stays close to a diff. The look is ours: upstream's lives in acn-selectstylesheet that ships separately, so there was nothing to carry over, and the panel instead reuses the frame, row geometry and accent washes of the Not-ready menu in the status strip.Two departures from upstream. The trigger has no
sizeprop and sizes itself off the rootdata-density, the way the switch does, because the operator's hardware decides that and not the call site. AndalignItemWithTriggerdefaults to false: Base UI's default overlaps the popup onto the trigger like a native<select>, which ignoressideandalignand does not match how this app's menus hang below the control they belong to.SCSS rather than Tailwind by the rules in the agent docs. Thirty-three palette variable references and five
color-mixcalls would every one be an arbitrary value, and the panel needs a::beforeon its scroll arrows plus a dark-theme override of both background and shadow.The popup is portaled into appRoot/ElementRoot rather than Base UI's default
<body>: the theme class anddata-densityare stamped on that root, so a popup mounted outside it renders light and compact whatever the user chose.
- (ui) Give the select a header slot and drop its scroll arrows
- Both changes come from the first real call site, the status strip's Not-ready menu, which the select had to be able to replace without losing ground.
SelectContenttakes aheader, rendered aboveSelect.Listrather than inside it.SelectLabelis no use here: it lives inside the group, so it scrolls with the options and spends 32px of their height -- a whole row at compact density. Measured against the menu being replaced, that dropped the options visible at once from nine to seven.SelectLabelstays for a heading that belongs to one group among several.The height cap goes from 22rem to 24rem. Base UI's option renders as a
<div>about 1.3px taller than the<button>the old menu used, which over nine rows is enough to tip a list that used to fit into scrolling. At 24rem the scroll threshold matches the old menu on both sides: nine rows fit, ten scroll.The hover-to-scroll arrows are gone. Hovering near the panel's edge scrolled the list out from under the pointer, which nobody asked for; a list that overflows now just has a scrollbar, as the menu being replaced did. Upstream ships the arrows, so this is a departure from the port.
A call site whose trigger is much narrower than its options can raise the panel's width floor with
--select-content-min-width;min-widthotherwise stays the anchor's width, and a Tailwind utility cannot override it from outside because the stylesheet already declares that property.
- (topmenu) Build the not-ready menu on the select
- The Not-ready reason menu was the only hand-rolled menu left in the app: a Semantic
Popupholding a list of buttons withrole="menuitemradio", its own stylesheet, and no keyboard navigation at all. Arrow keys did nothing, there was no typeahead, and Escape did not close it. Position came fromgetPopupPosition, which flipped the panel up when the trigger sat below 60% of the window height and could not shift it sideways -- with the panel wider than the pill, a trigger near the right edge overflowed the viewport.Rebuild it on lib/ui/select. Base UI brings arrow keys, typeahead, focus handling, Escape, and collision-aware positioning that shifts as well as flips. The semantics change from a radio menu to a listbox, which is the better fit: the operator is choosing a value, not invoking a command.
The trigger stays the strip's own
StatusOptionpill, passed through Base UI'srenderrather thanSelectTrigger.SelectTriggerwould add the input frame and a second chevron, and it keys its open state offdata-popup-openwhileStatusOptionstylesdata-open. Base UI's props land last in StatusOption's spread, sorole,aria-expandedandaria-haspopupcome out right.
modalis off: the strip sits in a shell the operator keeps working in, so the menu should not lock document scroll. The panel keeps its 17rem width floor through--select-content-min-width, since the pill is only as wide as its label.
aux-menu.module.scssgoes with it.getPopupPositionstays for AgentButton.
- (dial) Wip add an on-screen keypad for touch operators
- (otp) Generate the otp right after the user opens the otp page
- (task) Pick the task state from a select
- The state field was a FancyRadio: a row of coloured pills that grew with every state a queue defines, and that the read-only view repeated in full just to mark one of them current.
It is a select built from
lib/ui/selectnow. The colour a queue configures for a state becomes a dot in the trigger and in every option, and the read-only view is that dot with the state's name, which puts it in line with the Queue field above it.The dot puts the configured colour on the app's OKLCH rail, the way the task filter buttons already do. The colours are authored as pale pastels because elsewhere they are a pill background behind dark text -- as an 8px dot on white they are invisible. The rail keeps the hue, which is what the colour is actually saying, and caps the chroma by the source's own so a grey or white state does not come back pink.
FinalFancyRadio goes with the change; the state field was its only caller.
- (ui) Give the select an empty state
- A select with nothing to offer opened as an empty box, which reads as a popup that failed to load rather than as an answer. A task queue with no states configured is enough to get there.
SelectEmptyis a part like the rest, so the call site decides when it has nothing -- it is the only one that knows -- and owns the wording, which has to be translatable and specific about what is missing. The part carriesrole="presentation": it sits among the options inside Base UI's listbox and a line of prose is not an option, while a screen reader reports the empty list on its own.The state select in the task form says so when its queue defines no states.
Also fixes the
LongLabelsstory, which still described the ellipsis that the wrapping change replaced.
- (ui) Give the select an error state
- The select had no way to say that its value is wrong, so the task form drew its own Semantic label above the field and the field itself stayed neutral -- a red line of text pointing at a control that looked fine.
The select now takes an
errorand owns the whole state: it wraps its parts in Base UI'sField, turns the trigger red off thedata-invalidthatFieldsets, and renders the message underneath. Going throughFieldrather than anaria-invalidprop is what buys thearia-describedbyfrom the trigger to the message, so a screen reader reads the two together instead of announcing an invalid field with no reason. The wrapper isdisplay: contentsso call sites that were laying the trigger out themselves keep the layout they had.In the task form the state field now shows
meta.submitError, which is where the backend's objection to a state arrives, and drops theoneOfvalidator that guessed at the same thing from the options list. The message clears as soon as the state is changed, rather than sitting there red until the next submit.
- (ui) Move label and error out of the select into a field
Selectgrew a Base UIField.Rootand anerrorprop of its own, so the error state belonged to one control. Every other control would have had to grow the same prop and the same markup, and a label had no home at all --FinalTaskStateSelectleaned onLabeledFieldfor one and passederrordown for the other.
lib/ui/field/Fieldis now that shell: label, description and error around whatever control it wraps. Base UI wires the ids, so the control is named and described without the call site threading anything, and it picks updata-invalidfrom the field rather than from a prop.Selectgoes back to beingSelectPrimitive.Root.The frame a control draws -- box, border, fill, and the focus, disabled and invalid colours -- moves to the
control-framemixin beside it. The select and the input cannot share a component, one renders a button and the other an input, but they must not disagree on what a field looks like.
fieldErrorputs the submit-versus-validation rule in one place: a submit error stands until the field is edited again, a validation error waits for the field to be touched.
Fieldis styled in Tailwind rather than SCSS, on Tailwind's own scale instead of Semantic's metrics reproduced as arbitrary values. That drops the label by half a pixel and the type by three quarters of one against theTextLabelbeside it, which is the price of not tying new code to a dependency we are removing; CLAUDE.md now says so, so the next change does not quietly pair itself with Semantic again.--negative-text-colorgets atext-errortoken so the one colour left does not need an arbitrary value.🐛 Bug Fixes
- (topmenu) Restore the plain label on the reason-less Not ready option
- The StatusSwitcher commit routed both branches of AuxButton through the same
label, which isagentReasonwhen the agent is on a reason and theauxDisplaysetting asks for it. The second branch is the one taken when the team configures no aux reasons at all, and before the rewrite it rendered a hardcoded "Not ready". Feeding itlabelmeans a reason pushed by the server for a team that offers none in the menu would show up on an option that cannot change or clear it.Put the text back, so the branch says what it did before: no reasons to pick from, no reason to display.
- (topmenu) Let the bar wrap onto several rows on a narrow window
- Semantic's
.ui.menuis a nowrap flex row, and the Flexbox holding the left-hand cluster defaults toflex-shrink: 0, so the legacy top menu kept its natural width -- around 1400px at full-feature config -- whatever the window was, and simply ran off the right edge. The status strip, sitting in the right-hand slot, was the first thing to disappear..topMenuContainernow wraps, and its slots getmin-width: 0so a slot can be narrower than its content and hand the wrapping down to the row inside it.The wrapped rows are ordered to match TopMenuNext rather than the DOM: identity and nav, then CRM search and status, then the phone controls and the dial. The theme toggle, softphone trigger, handset icon and line number move out of the identity cluster into a
phoneControlsslot of their own for that, and a zero-height line-breaker forces the row above them, so they always travel with the dial rather than filling up the tail of the status row. Flexbox picks its wrap points from an item's preferred size before any shrinking, so the dial's 350px floor became a preference -- 200px, growing into whatever the row has left, capped at 350px -- otherwise it jumped to a row of its own around 600px while 200px above sat unused. Above 1400px, the same threshold TopMenuNext switches its layout at, the single row keeps its previous order untouched.TopMenuNext had the same class of bug at phone width, and had it before the status strip landed: src/index.scss forces
flex-direction: columnon every.ui.menuunder.contentbelow 400px, and in a column TopMenuNext'sflex-basis: 100%line-breaker claims the full height instead of the full width, breaking the bar into a second column that overflows sideways. It now opts out of that rule through adata-shellmarker -- the rule is there for the menus that lay out as one nowrap row, which this one does not -- and its right-hand slot takes the whole row at phone width, the way the old status button group did throughwidth: 100%.The status strip can also give width back now instead of clipping its last option: the options share whatever width the strip is given and the label ellipsises. Both are no-ops at its natural size, so nothing shifts on a window wide enough to hold it.
- (design) Drop the StatusSwitcher frame for pill-shaped options
- The bordered strip added a second rounded box to a bar that already has plenty of borders, and the edge-to-edge tint meant the active option's wash ran straight into that frame. Remove the border and give the strip inner padding instead, so the options float inside the bar with room around them.
--status-radiusmoves from--radius-lgto a full 500rem pill (the same value Semantic uses for@circularRadius) and now applies to.optionrather than the strip, so every option is a pill -- including a non-interactive active one, which would otherwise have kept square corners under its tint. Any radius past half the tallest option gives the same shape, so the density steps need no radius of their own.
- (design) Make the current status visible in the dark theme
- The active option mixed its wash into
--primary-background-color, but the strip sits on the top menu bar, which is--secondary-background-color. In the dark theme those are #222729 and #3e3e41, so a 15% tint of a neutral status colour landed within about 2% oklab lightness of the bar behind it -- the current status was, in practice, unmarked.Offline suffers worst, which is why it was reported: a deployment that never configures
agent_offlineleaves the colour null, so--status-colorfalls back to--tertiary-text-color, whose alpha thins the tint further.Pull the mix base and the tint strength out into
--status-active-surface,--status-active-tintand--status-active-tint-hover, keep the light theme on its current values, and point the dark theme at--tertiary-background-color(#4e4e52) -- the app's next elevation step above the bar, so the pill reads as raised before the tint says anything about hue. The slightly stronger 20% tint keeps the configured hue legible against that lighter base.
- (design) Rework the not-ready reason menu
- The reason picker was Semantic's vertical menu in a bare popup: square corners, full-width rows with no shape of their own, and nothing marking the reason the agent is currently on beyond Semantic's
activestyling, which the popup's own frame made hard to read.It is now a panel we own. A quiet line says what the list is for, the rows are pill-shaped buttons with a hover wash, and the current reason carries an accent bar on its leading edge plus a tick on the trailing one, so the selection reads without relying on colour alone. The list scrolls on its own, which keeps a team with thirty reasons inside a short window instead of pushing the menu past the bottom of it.
The rows are
menuitemradiorather than plain menu items -- one reason is current at a time -- so a screen reader announces which one that is. Sizing follows the display density off the app root, the way the other portaled menus do.
- (localization) Render the widget description without a spread Trans
- The widget description in GeneralSettings went through the runtime
<Trans {...description} />. The extractor reads JSX statically, so a spread hides theidfrom it and everylingui extractrun -- CI's included -- printed "Missing message ID, skipping" for that line.Nothing was actually missing: the descriptions are extracted from the
msgmacros in each widget's index, and the component only had to render an already-built MessageDescriptor. It now does that withi18n._(description), the way the sibling WidgetDropdown renders the widget title, which drops the@lingui/reactTrans import and leaves extract clean.
- (topmenu) Rework how the bar wraps at narrow widths
- The invisible line-breaker that forced a wrap between the status row and the call controls could not serve both bars. In the legacy bar it had to be switched off below 400px, where src/index.scss stacks the whole menu into a column and a
flex-basis: 100%item claims the full height instead of the full width, breaking the bar into a second column.Drop the breaker and let the slots wrap on their own. The phone controls move into the nav-link slot so they travel with the icons they sit beside, and below 700px the status slot becomes
display: contents, so the CRM search and the status strip lay out as bar items in their own right and the phone controls can take the search's line. A new $break-bar of 700px marks where the bar goes narrow, and the legacy bar joins.topMenuNextin being excluded from the column-stacking rule in src/index.scss, for the same reason.Below 700px the dial slot also drops the hairline Semantic draws on the right edge of every item: the dial and the call button want ~390px together, so each takes a line of its own and both edges are left hanging off the end of a row.
- (topmenu) Keep bar popups inside the viewport
- The hotkeys popup on the call icon ran off screen whenever the icon sat near the right edge of the bar. Semantic UI React builds Popup on Popper but ties the
preventOverflowmodifier to theoffsetprop and leaves it disabled otherwise, so nothing shifted the popup back in;flip, the one modifier that stays on, only swapsbottomfortop.Add a TopMenu-local Popup that wraps the portal-attached one and turns
preventOverflowback on, and route every popup in the bar -- both the legacy and the next shell -- through it. The modifier goes first in the list so a caller can still override it, since Popper merges by name and the later entry wins.Scoped to the top bar rather than fixed in lib/semantic-portal-attached, so the rest of the app keeps whatever positioning it has today.
- Prevent events and keypad visibility when dial is disabled
- Update missing translation, remove useless fragment
- (ui) Size the select trigger like the fields beside it
- The trigger rendered 28px tall next to Semantic inputs and dropdowns that render 38px, so the state field in the task form sat visibly short of the ones above and below it. Semantic's
@emSizeputs a rem at 14px here, somin-height: 2remlands 12.5% under what it reads as.The trigger gets a height of its own, in px because both of these are physical sizes: 38px to line up with a Semantic field at compact, and the tap targets the density levels aim at above that. Popup rows keep their compact value -- they are menu rows, they line up with nothing, and moving them would shift the not-ready menu under the density nobody chose.
- (ui) Wrap a long select option instead of cutting it off
.itemTextwaswhite-space: nowrapwith an ellipsis, so an option wider than the panel -- which is capped at 24rem -- came out as a stub. Task queues carry state names like "Odpověď na základní prvopoptávku- zamítnutí (výkon nenabízíme)", and an option the operator cannot read in full is one they cannot pick with confidence.A call site cannot fix this: the class sits on the
ItemTextinsideSelectItem, not on the element that takesclassName. Rows wrap now, withoverflow-wrap: anywherefor the label that is one unbreakable token.
- (ui) Stop the select value clipping descenders
- The trigger sets
line-height: 1to keep its own box predictable, and the value needsoverflow: hiddenfor the ellipsis. Together they make a clipping box exactly as tall as the font size, which cuts roughly 2px off both ends of the glyph -- the tail of a g or a y, and the diacritic on a capital. A value like "Assigned" rendered with the g sliced off.Giving the value a full line-height of its own makes the clipping box tall enough for the glyph. The trigger sizes off
min-height, so its height is unchanged at every density.🚜 Refactor
- (topmenu) Inline the status colour formatting
statusColoralready guards against an unset colour and hands onundefined, so routing the set case throughgetColoronly to prepend a#pulled in a helper whose whole purpose -- the #e0e1e2 default -- is dead here. Prepend the#directly and drop the import, so the comment about the unset case describes the one branch that handles it.- (topmenu) Size the status switcher off the root density
StatusSwitchertook adensityprop and stampeddata-densityon its own element, butappRoot/ElementRootalready stamps the setting on the app root for exactly this reason, and the rule for shared primitives inlib/uiis to size off that root rather than have it plumbed down --lib/ui/switchdoes.The prop was also inconsistent within its own feature:
aux-menu.module.scssalready read the root with:global([data-density='touch']) .panel, so the reason menu and the strip it hangs off used two different mechanisms. And being optional with acompactdefault, a call site that forgot it rendered a compact strip under a touch root with nothing to flag it.The token overrides in
status-switcher.scssbecome ancestor selectors, the call site no longer reads the setting from the store, and the Densities story fakes three roots the way the switch stories do.Trim the component's doc comment while here: the usage example duplicated the stories, which cannot silently rot and already carried the same prose, and the paragraph about
hasMenudescribed a prop ofStatusOption, where the same note already lives. What is left is the one thing the code does not say -- why the options are children rather than data.
- (ui) Move the popup overflow fix into the shared popup
- The previous commit turned Popper's
preventOverflowback on through a TopMenu-local wrapper around the portal-attached Popup. The wrapper adds a second Popup component and a second import path for no behaviour the shared one could not carry itself, so enable the modifier inlib/semantic-portal-attached/Popupand drop the wrapper, routing the bar back to the shared component.Every popup in the app now shifts back into the viewport instead of only the ones in the top bar. No call site passes
popperModifierstoday, and the modifier still goes first in the list, so a caller can override it by name the way Popper merges.
- (ui) Share one cx helper for class name joining
- Four components in lib/ui had grown the same
[a, cond ? b : '', className].filter(Boolean).join(' ')block. The pattern is there because a template string prints a literalundefinedinto the class attribute whenever an optional className is left out, which then shows up in the DOM and in snapshots.Hoist it into lib/ui/cx.ts and call it from Button, IconButton, SubmitButton and StatusOption. The
className = ''destructuring defaults go with it, since they only existed to feed the old join.It is clsx cut down to strings and conditionals. No objects, arrays or tailwind-merge: these primitives take
classNameas an addition to their own classes, never as an override, so there is nothing to merge.Rendered class strings are unchanged across all 208 prop combinations of the four components.
- (ui) Inline the select's string className bound
- The select parts narrowed Base UI's
className-- which isstringor a state callback -- down to a string through aStringClassName<Props>alias in a file of its own, so thatcxcould merge it. An intersection does the same work:Props & { className?: string }reduces the property tostring | undefinedwithout theOmit, so the alias and its import in seven files buy nothing over spelling the bound out at each part.- (ui) Give the select trigger a bare variant
- The status strip's not-ready pill is not a form control -- it brings its own frame and its own chevron -- so it reached straight for Base UI's
Select.Triggerwhile every other part of that select came fromlib/ui/select. One select composed out of two sources is confusing to read and easy to get wrong: Base UI concatenatesclassNameinmergePropsrather than overwriting it, so the field trigger's input frame leaks onto whatever the call site hands torender.
SelectTriggernow takes avariant.fieldis the form control and keeps the frame and the chevron;barecontributes neither and leaves the rendered element as the call site wrote it. Either way the trigger carries the button semantics and the popup wiring, so a call site never has to import from Base UI directly.
- (ui) Express the select's heights in rem
- The trigger height landed as a px literal, which pins it while everything it stands next to moves. Semantic sizes its own inputs and dropdowns off em, so a field beside the select tracks
@emSizeand a px trigger would not; the same holds for the tap targets above compact, which should grow with the base rather than stay put.A rem is 14px here, so the values are picked from the px targets and then written in rem: 2.75rem for the 38px a Semantic field renders at, 3.125rem and 4rem for the ~44px and ~56px the larger densities aim at. The comment carries that arithmetic, because the numbers read 12.5% larger than they render and the next reader would otherwise reach for round ones.
- (ui) Draw the control frame with tailwind instead of a sass mixin
- The frame a form control draws lived in a sass mixin that only the select could consume, through @use and @include. That kept the shared look of a field locked inside the stylesheet: an input or a textarea adopting it would have had to be a scss module too, and the values could not be reached from a react tree at all.
It is now a class string in lib/ui/field/control-frame.ts, which any control can apply. The seven palette colours it needs became @theme tokens;
inlineis not optional there, because the palette is applied to .elementRoot rather than :root, so a utility referencing a generated --color-* would resolve it where the source variable does not exist and lose the colour. Inlining puts var(--input-background-color) in the declaration itself, which also follows .dark for free.Sizes stayed on the px targets the mixin aimed at, written on tailwind's own scale: min-h-11 is the 38.5px a semantic input renders at, min-h-12.5 the ~44px of comfortable, min-h-16 the ~56px of touch. Density moved from a :global([data-density]) descendant selector to in-data-[density=...]. The 120ms transition became duration-100 and the
easekeyword the tailwind default, both differences nobody can see.
- (theme) Define the error text colour in the css theme
- The field's error state drew four of its five colours from the
@theme inlineblock in tw.css and the fifth, the text colour, from tailwind.config.ts. One state split across both token sources meant a call site could not tell from the class name which file defined it.The JS config is the v3 path, kept alive in v4 only by
@config, so the CSS block is the direction to consolidate in. Its--color-*namespace also generates the whole utility family, where the config'stextColorentry only ever producedtw:text-errorandtw:bg-errorcompiled to nothing.Named
negative-textrather thanerrorto match the neighbouring tokens. Reusing--color-negativewas not possible: that maps to--negative-color, the saturated red, not to--negative-text-color.
- (ui) Rename control-frame to field-styles
- The module was named after its single export, which left nowhere for the rest of the field's shared class strings to go without either a second one-constant file or a name that no longer matched its contents.
field-stylesnames it for the primitive it belongs to instead, so the label, description and error strings can joinCONTROL_FRAME_CLASSESthere as they move off the sass mixins.Contents are unchanged; the one importer and the stale reference in
select.scssfollow the new name.
- (task) Let the ui field label the state select
- The state select already drew its error through
lib/ui'sField, but its label still came fromLabeledField, which wraps the control in a plain div and renders the legacyTextLabelabove it. One control was therefore split across two labelling systems, and the label sat outside theField.Rootthat owns the field's invalid state.Passing
labeldown toFieldputs both halves in the same root, so the label is aField.Labelassociated with the trigger rather than anhtmlForguessed from the field name, and it picks up the invalid styling with everything else.LabeledFieldstays for the fields that have not moved yet.📚 Documentation
- (agents) Lint changed files rather than all of src
bun run lintruns oxlint over the whole ofsrc, where several hundred pre-existing errors bury anything the current change introduced, so a non-zero exit says nothing about the work in hand.lint:changedruns it over the files that differ fromorigin/master.Also note that
typecheckandformat:checkhave no changed-files variant, since their failures on untouched files are easy to misread as your own.
- (topmenu) Keep the status switcher docs free of app specifics
- The shared primitives in lib/ui/status-switcher documented themselves through one caller's domain: prop docs explained the colour as something the server config hands over, the non-interactive branch as "ACW is set by the system", the quiet variant as Offline, and the stylesheet named the top menu bar and cross-referenced TopMenuNext.module.scss. A primitive that describes a single call site's model stops reading as reusable, so these now say only what the component does with what it is passed. Domain names stay in the stories and tests, where they are sample data.
Also trim the narration: the three JSX comments in TopMenuLegacy explaining wrap/shrink, the line-breaker and the phone controls slot, and the Storybook component description's walk through each status. The reasoning belongs in the commit log, and the stories demonstrate their own states.
- (agents) Require named exports in new modules
- Every component added to the repo lately already exports named, but nothing said so, and the 634 files still on
export defaultread as the house style to anyone going by example.Record the rule along with the reason it is not applied retroactively, and note that expando members survive the named form so a compound component needs no special casing.
- (agents) Require one component per file and lean comments
- Two conventions that came out of review rather than out of the code.
One component per file, named after it, and a compound component as a directory of part files with no barrel: lib/ui has no re-export index today, and a barrel hides which call sites really use a part. This costs something on shadcn/ui ports, which ship every part in one file, so the styling exception is narrowed to say it covers class strings and not file layout.
Comments only for what the code cannot say. A rationale block above ordinary code goes stale, and the commit body is what still gets read months later.
🎨 Styling
- (topmenu) Reformat ReadyButton
- Reflow the imports, the selector call and the empty-channels condition to double quotes and an 80-column print width, and drop the comment above the empty-channels branch.
This does not match the repository formatter: oxfmt uses single quotes and a wider print width, so
bun run format:checkreports the file as unformatted.
- (topmenu) Reformat the status switcher files with oxfmt
- Both files still carried the old double-quoted, 80-column layout. Running oxfmt over them switches the quotes and rejoins the wrapped import list, props destructuring and conditions, so
format:checkis clean for these files. No behaviour change.- (topmenu) Strip comments from the next bar stylesheet
- Removes the commented-out
.lineBreakerblock and the note above the phone-width container query. No rule changes.- (topmenu) Strip comments from the legacy bar stylesheet
- Removes the commented-out
.lineBreakermedia query and the notes on the status slot wrap, the dial slot hairline and the narrow row order. No rule changes.- (ui) Put one control frame class per line
- The frame's classes were packed several to a line by no particular rule, so the line holding the focus ring also held the two disabled states and a diff touching one of them rewrote all three. Splitting to one class per line makes blame and review per-class, and groups the states that belong together.
No class is added, removed or reordered within a group, so the compiled output is unchanged.
- (ui) Drop the explanatory comments from the select stylesheet
- Four of the blocks in select.scss explained the rules rather than something the rules could not say: what an empty-state row is for, how the density custom properties are inherited from the app root, why the value has its own line-height, why a long option label wraps. All of it is either visible in the declaration below or belongs in the commit that introduced it.
What stays is the set that a later reader would otherwise "correct": Semantic's z-index, the absolute-positioning base Base UI's scroll arrows need,
alignItemWithTrigger,data-highlightedover:hover, and why the group is notdisplay: contents.⚙️ Miscellaneous Tasks
- Add /c and /cp commit skills
- Commit messages in this repo have to parse as Conventional Commits or cliff.toml drops them from the changelog and
just releasemisreads the next version. That rule lived only in CLAUDE.md, so every commit written with an agent re-derived it from prose and got the scope or the type wrong often enough to matter./c captures the whole procedure instead: read the diff before writing about it, run typecheck, lint:changed and format:check and fold any resulting fix into the same commit, pick a scope from the ones already in use, and commit the entire tree as one conventional commit. It refuses to touch CHANGELOG.md, changelogs/ or a version bump, since those belong to
just release./cp is /c plus a push. It keeps the branch guard out of /c, where it would be dead weight: master and dev are not push targets, so it stops and asks for a branch name rather than committing there, and it passes on the GitLab merge-request URL from the push output.
Both are marked disable-model-invocation, so they only ever run when the user types them.
- Drop the co-authored-by trailer from /c
- The commit skill's message template carried a
Co-Authored-By: Claudeline, so every commit made through /c ended with it. We do not want that trailer in this repository's history.Removing it from the template alone would not be enough: the Claude Code environment instructs the agent to append that trailer by default, so the skill now states explicitly that the message ends with the body and carries no trailers.
- (storybook) Fold the Status story into the StatusSwitcher one
- Two stories covered the agent status strip: the old
TopMenu/Status, which mounted the connected component over a mocked store, andUI/StatusSwitcher, written with the strip itself. The new one is the better of the two -- it reaches states the old one could not stage from Redux, among them the open reason menu, the blinking offline option, the three densities and the option subsets a deployment can configure -- so keep it and drop the old one.It takes over the
TopMenu/Statustitle as well. The component lives inlib/ui/status-switcher, but the top menu is its only call site, so that is where anyone looking for it will look; the docs blurb now names both the component directory and the file holding the Redux wiring so the other direction works too.The deleted file was also the storybook skill's example of story-level decorators, which
SoftPhonePopupTrigger.stories.tsxalready demonstrates.
- Format stylesheets with oxfmt in zed
- CSS and SCSS both declare a prettier parser, so with no explicit formatter Zed's format-on-save reached for its bundled prettier rather than oxfmt. Prettier disagrees with
bun run format:check-- on quote style among other things -- so saving a stylesheet in Zed left it failing the project's own check.- Drop the stylesheet formatter note from zed settings
- The explicit
formatterentries for CSS and SCSS stay as they are; only the comment above them goes.- (dial) Satisfy oxlint on the space-stripping regex
- oxlint's require-unicode-regexp flagged the regex that strips spaces out of the dialled number as an error. The pattern is a single literal space, so the 'u' flag changes nothing about what it matches -- it only puts the file back in the clear for lint:changed.
- (storybook) Cover the select's header slot
- The slot went in without a story, so the only way to see what it does was to read the stylesheet.
WithHeadergives it more options than fit, which is the point worth seeing: the header holds still while they scroll.HeaderWithGroupsputs it next toSelectLabel, which is the one way to reach for the wrong one -- the header belongs to the panel and never scrolls, a label belongs to the group under it and goes with the options.
- Include some gitignored files in zed scan
- Remove unused @types/react-html-parser
- (ui) Add a tailwind-aware class merge helper
- cx() joins class names, which is all most of lib/ui needs because its variants live in scss. Where a component does compose tailwind strings -- button/index.tsx and StatusOption.tsx today -- joining is not enough: the className prop lands last in the attribute, but CSS resolves equal-specificity utilities by their order in the generated stylesheet, so
The prefix option looks redundant and is not. tw: parses as a variant, so bare twMerge already merges our classes correctly -- it matched the configured instance on 20 of 21 probes. The exception is the broken focus:tw:ring-2 form: bare twMerge reads it as tw:focus:ring-2 and lets a later ring utility swallow it, which would hide the 45 dead classes CLAUDE.md counts rather than leave them visible. The test pins that case along with the palette tokens, whose stems overlap real utilities (tw:text-sm and tw:text-primary must both survive).
Nothing consumes cn() yet; picking the call sites is a separate change. Note that a class string tailwind-merge has not seen costs ~5.4us against ~100ns for cx, so per-render arbitrary values like tw:mt-[${x}px] defeat its cache and do not belong in a cn() component.
- Teach the tailwind language server about cva and cx
- The tailwind language server only offers completion, hover and its own lint inside class and className attributes. The moment utilities move into a cva() variant table or a cx() argument they are ordinary strings to it, so the editor goes quiet exactly where the classes are hardest to get right -- including the prefix-order trap, where focus:tw:ring-2 compiles to nothing and nothing in the editor says so. classFunctions puts those call sites back under the server.
◀️ Revert
(deps) Drop class-variance-authority
- Reverts 57a4910. Nothing ever imported it, and the component it was added for turned out not to want it: the control frame in lib/ui/field varies by DOM attribute -- data-invalid, data-disabled, the ancestor data-density -- rather than by prop, and cva without a variant axis is by its own source exactly cx:
if (config?.variants == null) return cx(base, props?.class, props?.className)
The original message justified the dependency with a chain of ternaries in the select that does not exist; that select styles its states in scss. The genuine candidate is Button, which really does have three prop-driven axes, so this comes back when Button is converted rather than sitting in the manifest until then.
tailwind-merge stays: it is not variant machinery, and the select needs it as soon as a control frame string meets an incoming className.
[9.38.0] - 2026-09-10
🚀 Features
- (ui) Add Switch and replace the Semantic toggles with it
- Port of the shadcn/ui switch on Base UI, with the colours mapped onto our palette variables so it follows the active theme. Takes an optional
labeland wraps itself in one, which is what the SemanticCheckbox toggledid and what every call site here needs;classNamealways lands on the switch itself so it does not change target depending on the other props.Replaces
Checkbox toggleacross the widget settings and views. The onChange signature goes from Semantic's(event, { checked })to plain(checked: boolean), so the handlers lose their unused event parameter and thechecked ?? truefallbacks that existed only because CheckboxProps haschecked?: boolean.Base UI dispatches pointer events, which jsdom does not implement -- without the PointerEvent stub in vitest.setup.ts a click throws instead of toggling, so tests would silently see no interaction.
CLAUDE.md gains the Tailwind-or-SCSS section: which of the two to reach for, that the
tw:prefix comes first (focus:tw:ring-2compiles to nothing, and the repo already has 45 such dead classes), and that a faithful upstream port keeps its upstream class strings so the next re-port stays a mechanical diff.
- (ui) Size the switch from display density, drop its size prop
- The switch shipped with shadcn's
sizeprop, which lets each call site decide how big a toggle is. That is the wrong owner: the size of a tap target follows the operator's hardware, not the surface. Dispatchers on the BeFREE 10 panels got the same 32x18px control as a desktop agent, and no call site ever passedsizeanyway -- only the story did.The switch now reads
data-density, whichElementRootstamps on the app root, through Tailwind'sin-*variant (:where([data-density='touch']) &). A shared primitive inlib/uimust not select from the store: Storybook, the tests and store-less surfaces render it too. The unprefixed classes stay compact, so both the default level and anything rendered outside a density root are unchanged.Track stays 2x the thumb and the height the thumb plus 0.15rem, so the knob still lands flush: 32x18 compact, 40x22 comfortable, 48x26 touch. The tap target is a min-height on the label wrapper (44px comfortable, 56px touch) rather than an overlaid hit area, which would steal clicks from rows above and below.
Documents the shape as Style C in the display-density skill, next to the widget token tables and the Semantic shell variables it does not fit.
- (email) Enable replying to send email feature flag by default
🐛 Bug Fixes
- (ui) Scale the switch label with density, anchor tap targets in px
- Two things the previous commit got wrong, both from assuming a 16px root.
Semantic's
@emSizeis 14px andglobals/site.lesssetshtml { font-size: @emSize }, so every rem in the app -- Semantic's and Tailwind's alike -- is scaled to 14px.min-h-11andmin-h-14therefore rendered 38.5px and 49px, not the 44 and 56 they read as, leaving both levels under the target they were written to hit. Tap targets and the track are now in px, since a finger does not shrink with the type scale; compact keeps its original rem values so it stays byte-identical to what deployments render today.The label also kept the surface's font size while the control grew, so a touch panel showed a 26px toggle labelled in 12-14px text -- the widgets that host switches (Calls, Queue, AutoSpy, the settings forms) have no density tokens of their own, so nothing else was going to scale it. The label now steps with an em multiplier, matching the TopMenu shell's 1.0625 / 1.1875: relative so it composes with a surface that does scale its own text, instead of overriding it the way a
text-lgwould. At the 14px root that puts touch at ~16.6px, above the 16px that stays comfortable on a 123 DPI panel at arm's length.Measured in a browser: 28x16 / 40x22 / 48x26 track, 21 / 44 / 56px label row, 14 / 14.9 / 16.6px label text, knob flush at every level.
Records the rem trap as pitfall 8 in the display-density skill, with the two existing comments it makes optimistic.
- (dial) Keep the dial input readable after a theme switch
- The whisper dial input declared no colour of its own, so it took Tailwind preflight's
input { color: inherit }and inherited.ui.menu .item, whose colour Semantic transitions over 0.1s. A text control whose colour arrives as an animated inherited value is not repainted when the value settles, so switching to dark mode left the old near-black glyphs -- in practice the "Name or number" placeholder -- on the new dark background. Focusing the field and leaving it again forced the repaint and the colour looked right from then on, which is why the field appeared to fix itself.Declare
--primary-text-coloron the input instead, so the theme switch is an instant, element-local style change rather than an inherited animation, and give the placeholder the app's--placeholder-text-colortoken rather than preflight'scurrentColormix, which went stale the same way. Every other input in the app already sets both directly; this was the only one relying on inheritance.
- (reports) Tell the user the profile select is empty
- Clicking the profiles select with no saved profiles opened a menu with nothing in it, which reads as a broken control rather than as an empty list. Semantic's own noResultsMessage does not help here -- it is only rendered for search dropdowns -- so the empty state is a disabled placeholder option instead. Disabled options are inert in both the click and the keyboard path, so it cannot be selected and the Save and Remove buttons stay disabled.
- (uphone) Put unattended bridge ringing behind a feature flag
- The distinctive ring tone for unattended bridge calls waiting in a queue was always on. It is a workaround: the call should ring on the device itself, since call events are distributed to every agent assigned to the dispatching room, so the standard call ringing already covers it.
Gate it on the new
ff.unattendedBridgeRingingflag, defaulting to false, so deployments get the standard ringing path and the hack stays available only where it is explicitly enabled.
- (email) Always show email subject, to, cc, bc, ... in column
- (tabs) Navigate to a temp tab whenever activate is requested
- The saga guard required both activate and an unset link before calling navigate(), so any caller combining activate: true with a real link never got auto-focused. addTask sets both on its new task tab, so clicking "Create task" created the task but silently left the tab unopened. Drop the link condition; activate alone is enough to intend navigation, and no other caller relied on suppressing it when a link was present.
🚜 Refactor
- (theme) Derive every interactive blue from one accent hue
- The blue that means "actionable" or "switched on" existed four times over: --button-blue, --focus-color, --brand-color-secondary and the OKLCH --color-blue-hue. Light theme was already coherent by accident -- #2185d0 sits at hue 246.9, exactly --color-blue-hue -- but dark had drifted to 268 and --brand-color-secondary had no dark variant at all, so anything using it looked identical in both themes.
Introduce --accent-hue / --accent-color / --accent-hover-color in palettes.scss, derived from --color-blue-hue, and point the old names at them as aliases so no call site has to change and the Semantic .variables files keep resolving. Light theme values are bit-identical to before; only dark moves, onto the shared hue.
--checkbox-checked-color loses its dark override: it was a second, hand-copied it clears 3:1 in both themes, at the cost of a lighter mark in light theme than Semantic's near-black.
Also expose accent/accent-hover as Tailwind colours and move EmailButtons off --brand-color-secondary, which is a brand token rather than a state one.
⚙️ Miscellaneous Tasks
Satisfy oxlint in RuleRow and ReportDownload
- Both files were touched by the Switch migration and carry oxlint errors that predate it or came in with it:
RuleRow is a
React.forwardRefwith nodisplayName, so it shows up asForwardRefin the component tree and in test output.ReportDownload destructured
errorsfrom the final-form render prop without using it.The
content-dispositionfilename regex lacked theuflag.The component's return was wrapped in a fragment holding one child.
The two
"... time" is invalid.messages used bare double quotes in JSX text.Escaping the quotes as
"leaves the extracted msgid untouched -- JSX decodes the entity before Lingui sees it -- so the existing Czech translations still match;lingui extractreports no change and no missing messages.
- Add markdown format metadata to changelog
- Add project level zed settings
- (oxlint) Lint against the merge base instead of origin/master
- The lint job diffed the working tree against origin/master, a remote-tracking ref the runner keeps in a recycled, shallow clone. Once that ref fell behind, every file master had moved since then landed in the lint set, and their pre-existing errors failed pipelines for branches that never touched them.
[9.37.0] - 2026-09-09
🚀 Features
- (dev) Run the dev server without sudo and allow parallel instances
- Binding port 443 required root because the uauth origin allowlist in Traefik matches the exact string
https://dev.insoft.cz, so any other port made the browser send a differentOrigin. The dev proxy now setsOrigin,RefererandHostitself fromDEV_ORIGIN, which makes the local port invisible upstream.Hostmatters as much asOrigin: with the port still attached the backend answers 302 tohttps://dev.insoft.cz/..., i.e. to a port nothing listens on.HMR no longer uses a hardcoded port 8888. It rides the main HTTPS listener and the
0.0.0.0/0sentinels let the client derive host, port and protocol from the page, which was the other thing that made two dev servers collide.
scripts/dev-agent.mjspicks a free port from 8443 up, verifies the prerequisites with actionable errors, waits until the first build is servable and records the URL in.dev-server.json, so several dev servers -- one per git worktree -- can run side by side and every agent can start its own.
dev-fe-onlyno longer shells out to sudo; running as root left root-owned files in build/, .swc/ and node_modules that broke later non-root runs.README documents the proxy/no-proxy split, LOG_REDUX and the corrected
lingui-compileinvocation; CLAUDE.md points agents at the same workflow.
- (e2e) Drive the app in a real browser to verify a change
- Operator is an SPA, so
curlonly ever returns an empty#app: seeing whether a change works needs a real browser.@vitest/browserwas already declared but had no provider installed, and it namesplaywrightas its peer, so this installs the provider the repo had already picked rather than a new stack.- (email) Surface failing e-mails in the quick filters
- Customers reported e-mails silently failing to send and asked for a way to notice this without digging through the full list. The quick-filter bar now shows a Failed preset - styled with the app's existing negative/alert palette to draw the eye - that only appears once there is at least one failed e-mail to see.
Every quick filter also gets a live count badge, so agents can tell how many e-mails a preset holds before switching to it. Counts come from a new tanstack-query-based email list query that mirrors the real paginated list request (queue scope, date/tag filtering) rather than a bespoke count endpoint - the first tanstack query in this widget, ahead of a planned move of the whole list off redux-saga.
🐛 Bug Fixes
- (storybook) Wrap stories in a redux Provider
- ElementRoot has selected display density from the store since 85435210e, but the preview supplied no Provider, so every story failed to render with "could not find react-redux context value".
Register the Provider as the last decorator. Storybook composes decorators innermost-first, so the last entry is the outermost wrapper and the only position that also covers ElementRoot's own selector.
Build the store from the real root reducer rather than redux-mock-store: selectAppSettings reads state.app through getIn, so it needs the immutable initial state that each slice already provides.
- (email) Label the new state filter as "new" instead of "delivered"
- "delivered" only made sense for inbound emails, where the message has technically arrived but nobody has looked at it yet. Outbound emails in the New state are drafts the user just started writing - nothing has been delivered anywhere.
Both directions share a single EmailState.New value, so this is a labelling change only: same checkbox name, same filter value, no behaviour change. "new" also matches the badge rendered by State.tsx, which the filter previously contradicted even for inbound.
- (email) Type-check Filter.tsx and fix Trans label shorthand
- Semantic UI React's label shorthand types the
childrenfield as a render function whenever the target props'childrenis optional (as HtmlLabelProps's is), so passingas children didn't type-check. Switch to i18n._(msg ...) for checkbox/radio labels, and add proper types (and matching null-guards) to the onChange handlers that used implicitany.- (email) Respect hideInlineAttachments feature flag
- (e2e) Wait for the page to settle before screenshotting
#appno longer holding the boot placeholder only means React mounted. The login iframe then loads an app of its own, and screenshotting at that moment produced an all-white image -- which reads as "the app is broken" to whoever, or whatever, looks at it next. Found by taking a before/after shot of a visible CSS change and getting two blank images.Websockets do not keep a page from reaching
networkidle, so HMR and/wsdo not interfere; a backend that never settles is reported rather than fatal.
- (logger) Send remote logs to the port the app is served from
Logger.buildUrltakes the hostname fromwindow.locationbut the port only from configuration, and the constructor defaulted that port to an empty string. The result was alwayshttps://<host>/llog/http, which happened to be correct only while the app was served on 443. On any other port the logs went to a port nothing listens on, and the page's ownconnect-src 'self'blocked them outright -- so a dev server on 8443 silently had no remote logging at all.
DEFAULT_ENDPOINT_CONFIGalready defaults the port towindow.location.port; this makes the Logger agree with it. Deployments that setapiin the configuration are unaffected, and in production the page is on 443, wherewindow.location.portis empty and the URL is unchanged.
- (e2e) Report unhandled rejections, the dev overlay, and a real login
- (notifications) Handle Background Sync being unavailable
reg.sync.register()sat inside a.then()whose promise was never returned, so the outer.catch()never saw its rejection. Background Sync is absent in some browsers and can be refused by policy, and when it is, the rejection went unhandled: in dev that puts "Uncaught runtime errors: Background Sync is disabled" over the whole app, and in production it is a silent unhandled rejection.The failure is now logged as a warning, since losing Background Sync degrades incoming-call notifications rather than breaking the app.
(dev) Keep HMR working when the dev server is reached through Traefik
- Without
DEV_PROXYthe HMR client does not derive the socket URL from the page: rspack builds an absolutews://<devServer.host>:<devServer.port>from the config. A developer who serves the app through a local Traefik therefore loads the page from one origin and the socket from another, so dropping the explicitws://entries from the injectedconnect-srcblocked it outright:Connecting to 'ws://127.0.0.1:8888/ws' violates the following Content Security Policy directive: "connect-src 'self'". The action has been blocked.
The entries come back, now built from
HOST/PORTinstead of the hardcoded 8888 they were before, and only in the proxy-less mode -- with the proxy the socket really does ride the page's own origin, which'self'covers.The HMR path moves out of the
DEV_PROXYbranch as well./wsis where the app's own backend socket lives (Connector, same origin by default), so without a dedicated path the two collided in the proxy-less mode.
- (styles) Put the tw prefix before the variant so the styles compile
- Tailwind v4 wants the prefix first:
tw:focus:ring-2, notfocus:tw:ring-2. The second form matches no utility, so it silently produced no CSS -- 45 classes across 12 files, almost all of them hover, focus, disabled and breakpoint states that therefore never applied.Verified by compiling src/tw.css against src/ and checking the previously dead selectors are now emitted.
- (button) Drop the empty SubmitButtonProps interface
export interface SubmitButtonProps extends BaseButtonProps {}declares no members of its own, which oxlint rejects under typescript(no-empty-object-type). A type alias says the same thing without the empty body, and the name stays exported for callers.- (email) Clarify the Czech label for the Failed quick filter
- "Neodeslané" (not sent) read too close to an in-progress sending state. Reuse "Odmítnuté" instead - the term the per-row status badge already shows for the same EmailState.Rejected state - so the filter name matches what the user actually lands on.
🚜 Refactor
- (dev) Settle on a single dev:fe-only script
📚 Documentation
- (agents) Require conventional commits
cliff.tomlalready parses the history as conventional commits and hasfilter_unconventional = true, so anything that does not parse silently vanishes from the changelog, andjust releasecannot see it when deriving the next version. The convention was only implicit in the log, and the threeFix lint/Fix testscommits at the tip are what that costs.CLAUDE.md now spells out the format, the types
cliff.tomlgroups, how to find an existing scope instead of inventing a synonym, and that releases go throughjust release..cursor/rulescarries a compact version of the same rules, since it applies to every Cursor agent run.
- (readme) Describe how ssl keys can be obtained
🎨 Styling
- Bring three stale files back to oxfmt output
- TopMenu/index.scss had a double blank line, EmailViewer.tsx an unwrapped nested destructuring and recent-call-item.tsx an arrow body split across two lines that fits on one.
oxfmt --check srcflagged all three; no behaviour change.⚙️ Miscellaneous Tasks
- Add claude skills
- (task) Satisfy oxlint in the time tracking widget
- oxlint reported two unused bindings here.
handleDurationChangehad no consumer left -- the only one was a commented-out<DurationInput>, removed along with it -- and thecatch (e)parameter was never read, so the clause now binds nothing.[9.36.2] - 2026-09-02
🐛 Bug Fixes
- (email) Do not close 'new email' modal when user clicks outside
[9.36.1] - 2026-09-02
🐛 Bug Fixes
- (dial) Add localization to device loading error messages
[9.36.0] - 2026-09-02
🚀 Features
- (email) Style reply surface only for reply/forward drafts
- Show the purple reply surface and the "Reply draft" / "Forward draft" header only when the editor is actually editing a reply or forward. Brand-new outbound emails now use the neutral surface and omit the draft-kind label, since there is no original email to relate to.
🐛 Bug Fixes
- (topmenu) Improve dial loading and error states
[9.35.0] - 2026-08-31
🚀 Features
- (email) Allow agents to self-assign unassigned emails
- Add an "Assign to me" button on the agent row of EmailInformation that is visible to non-supervisor users when no agent is assigned. Clicking it patches agent_id via email.edit with the current agent's id, reusing the existing edit-lock rules.
- (email) Gate sent mail reply behind enableSentMailReply flag
- (email) Open original email when reply draft exists
🚜 Refactor
- (config) Extract feature flags into featureFlagsParser
[9.34.3] - 2026-08-31
🐛 Bug Fixes
- (connector) Prevent refCountUnsubscribe from tearing down legacy subscriptions
- Legacy subscribe() and refCountSubscribe() tracked subscriptions in separate structures, so a refCountUnsubscribe() call that dropped a topic's refcount to zero would send frontend.unsubscribe to the backend even when the topic was also subscribed via the legacy path (e.g. the provisioning saga at app start), silently killing events the app still expects to receive.
Pin the refcount of legacy-subscribed topics to +Infinity and share a private sendSubscribe() helper between both paths, so refCountUnsubscribe can never bring such topics to zero while refCountSubscribe no longer inflates its own counts through the public subscribe().
[9.34.2] - 2026-08-23
🐛 Bug Fixes
- (login) Allow user team to be null
[9.34.1] - 2026-08-23
🚀 Features
- Added INSOFT kiosk deploy directory (for BeFree)
- (core) Make nextDeviceSync ff true by default
🐛 Bug Fixes
- (device) Make nextDeviceSync default true even if ff section not defined
[9.33.0] - 2026-08-21
🚀 Features
- Mirror tanstack device query into Redux behind nextDeviceSync
- Adds a NextDeviceSync bridge that copies the paired device from the tanstack useDevice query into the legacy app.device slice while the ff.nextDeviceSync flag is on. Full device shape is preserved (matches UM_LOGIN_SUCCESS), so uPhone and other legacy selectors keep working and now react to device changes coming from other windows via user.mobility.login/logout events.
🐛 Bug Fixes
- (uphone) Scope unattended bridge ring to role's dispatch
- Previously the queue ring fired for every unattended bridge, alerting agents about calls in dispatching workspaces they do not manage. Match bridge dispatch_id against the current role's dispatch_id so only the relevant agents are notified.
- (integration) Do not skip onIdentify callback when nextDeviceSync is enabled
- (device) Make sure that both id and deprecated device_id properties are always present on device object in store
[9.32.0] - 2026-08-20
🚀 Features
- (email) Allow replying to sent emails
- Add reply, reply all and forward buttons to outbound sent emails so an agent can continue the thread without opening the original inbound message. The controls are hidden once the sent email itself has been replied to, and each button is disabled when the parent (reply_to) email already has a reply assigned, preventing a second reply from displacing the existing one.
Introduce a reusable TanStack Query options factory for loading emails by id and use it from both SentReplyButton and EmbeddedEmailView. Also expose reply_to on the email parser so the parent lookup has the id it needs.
- (uphone) Ring for unattended bridge calls when agent is ready
- Play a distinct ring tone (option 8) while there are unattended dispatching bridge calls in the queue, but only when the agent's status is ready so busy or paused agents are not interrupted.
🐛 Bug Fixes
- (email) Temporary hide sent email reply buttons
[9.31.0] - 2026-08-20
🚀 Features
- (topmenu) Widen menu items on comfortable/touch density
- Icon-only Dial buttons (Hold, Transfer, Mute, Hangup, Answer, Resume) were ~64px wide against a 56px min-height on touch density, narrow enough for resistive-panel taps to slip between adjacent buttons.
Add --tmn-item-hpad mirroring the existing --tmn-item-vpad pattern: 0em on compact (preserves legacy), 0.35em on comfortable, 0.7em on touch. Applied shell-wide so Status, Line, and the copy-number item scale consistently with the density contract.
- (audio) Unify profile logic and add trigger badges
- Extract audio profile parsing/storage/matching into a shared core module and use it across settings UI, hotkey manager, and top-menu triggers.
Add profile indicators to both SoftPhone and UPhone triggers with Lucide badge icons, and rename profiles to headset/handset with backward-compatible loading from legacy silent/loud localStorage keys.
- (topmenu) Widen menu items on touch density
- Icon-only Dial buttons (Hold, Transfer, Mute, Hangup, Answer, Resume) were ~64px wide against a 56px min-height on touch density, narrow enough for resistive-panel taps to slip between adjacent buttons.
Add --tmn-item-hpad mirroring the existing --tmn-item-vpad pattern: 0em on compact and comfortable (mouse users don't need it), 0.7em on touch. Applied shell-wide so Status, Line, and the copy-number item scale consistently with the density contract.
🐛 Bug Fixes
- (localization) Add czech translation to audio profiles
- (crm) Align crm reference label with its description
- (email) Display email from/to crm contact using crm reference utility instead of custom styling
🚜 Refactor
- (dispatching-rooms) Hoist handset hotkeys to top level
- The handset-up (answer) and handset-down (hangup) hotkeys were bound to components that only mount conditionally: HANDSET_UP_HOTKEY in CallHotkeys, which renders only when Dial has an active call, and HANDSET_DOWN_HOTKEY in ParticipatedBridgeView, which renders only when the DispatchingRooms widget is on screen. Operators lost the hotkeys whenever those components were absent, including when the backend failed to include the call in the active-calls stream.
Register both hotkeys once in a new GlobalCallHotkeys component mounted in TopMenu. Since call.answer and call.hangup are not scoped to a specific call, the handlers forward the keypress unconditionally and let the backend pick the target.
Move the ari.dispatch.bridge.* cache invalidation out of DispatchingRoomsView into a useDispatchingRoomsSubscription hook mounted in TopMenu so the cache stays fresh for UPhoneInternal even when the widget is hidden.
⚙️ Miscellaneous Tasks
- Disable lazy compilation
[9.30.0] - 2026-08-18
🚀 Features
- (core) Switch audio profiles using F15 (headset) and F16 (handset) keys
- (display-density) Enlarge dial font size in comfort and touchscreen display density
- (director) Sticky device selection
- Add a persistent device_id and wire it through iam/pick/pick_sticky. Refactor BottomMenu's chess-icon into a click-triggered popup hosting both "Use here" and "Always use here" actions plus a pinned-device indicator. Admin director page gains a Device column, pinned-device card, and per-session action buttons.
- (director) Add Device tab to softphone popup
- Colocate the director picker with the softphone controls where the underlying WebRTC-election concept already applies. Keypad and Audio tabs hide in standby (this tab isn't main), and the UPhone popup is now openable in standby so users can reach Device and promote this tab to main. The BottomMenu chess-icon stays as a parallel affordance during migration for users without softphone or UPhone configured.
Enable "Always use here" whenever "Use here" is enabled: pick_sticky is a superset of pick, and pick clears the sticky pin. The only true no-op is being main and already pinned here.
Add density support (compact, comfortable, touch) to the director status popup body via a local Style A token table, and polish the visuals: theme-aware secondary text color, dedicated online/offline dot, thumbtack icon on the sticky button, subtle divider above the preference footer.
🐛 Bug Fixes
- (Connector) Skip RPC when refcount subscribe/unsubscribe has no topics
- refCountSubscribe and refCountUnsubscribe previously called subscribe/ unsubscribe unconditionally, sending a frontend.subscribe or frontend.unsubscribe RPC with no topics whenever every requested topic was already tracked (refcount just incremented, or decremented above zero). This also produced misleading log lines like "Unsubscribed from UCS events:" with nothing after the colon.
- (uphone) Do not log about skipping call event that user is not participant of
🚜 Refactor
- Consolidate identity constants and add DEVICE_ID
- Extract INSTANCE_ID, SESSION_ID, SESSION_ID_SHORT into a new src/lib/idents.ts module and add DEVICE_ID (persisted in localStorage) for the upcoming director sticky-device feature. Rename _IDENT suffix to _ID for consistency and update all import sites.
[9.29.1] - 2026-08-10
🚀 Features
- (dial) Grow dial input to fill whole space to fit longer names
- (dial) Make dial number suggestsions respect display density settings
- (recent-calls) Dial number of recent call on click
🐛 Bug Fixes
- (density) Fix missing paddings on small display in compact density
- (recent-calls) Localize tooltip
⚙️ Miscellaneous Tasks
- Replace debounce with configurable debounce
[9.28.2] - 2026-08-03
🐛 Bug Fixes
- Avoid crashing api integration by undefined 'this'
[9.28.1] - 2026-07-29
🐛 Bug Fixes
- Change background color of top menu from brand orange color to light gray
[9.28.0] - 2026-07-28
🚀 Features
- Hangup active bridge call on handset down
- (RecentCalls) Scale rows with user display density
- Read
main.densityfrom application settings and drive row padding, gaps, minimum height, direction icon, primary/meta type, and the Semantic play icon size from a per-widgetDENSITY_TOKENStable. Compact preserves the original rendering so existing deployments do not shift; comfortable and touch target BeFREE 10 operators.🐛 Bug Fixes
- Remove legacy hotkey sequece listeners
🚜 Refactor
- (density) Move Density enum to lib
- Application settings previously reached into widgets/DispatchingRooms/density to import DENSITY_VALUES, and RecentCalls had its own duplicate of the same tuple. Extract the enum and the Density type to src/lib/density so both widgets and the settings schema share one source of truth. Per-widget token tables stay local: the fields DispatchingRooms and RecentCalls need do not overlap enough to justify a shared composition yet.
[9.27.0] - 2026-07-28
🚀 Features
- Make top menu respect display density settings
- Skip device identification if device is already paired
[9.26.0] - 2026-07-27
🚀 Features
- (dispatch-bridge) Hide other's attended calls that are already connected because they are not important for others anymore
- (dispatching) Add configurable display density
- Dispatching runs on BeFREE 10 touch panels (10.4", 1024x768) where the compact layout was sized for 20 concurrent calls, but real load is around five. Compact, comfortable and touch presets trade that unused density for legible text and touch targets clearing the ~9mm minimum.
Density is an application setting rather than a widget one, since it follows the operator's eyesight and hardware, not a widget instance. Compact reproduces the previous rendering and stays the default.
Also fixes two pre-existing defects: a malformed
active:variant that left cards without press feedback, and a missing ucsMirror.users mock that crashed the stories. The action row moves off Semantic's button, whose higher-specificity styles made Tailwind sizing inert.
- (dispatching) Enlarge touch density typography
- Field testing on the BeFREE 10 showed the caller name still reading too small. text-xl shares the line height of text-lg, so the identity and timer grow without changing card height.
Moves the status line into the density tokens as well; it was pinned at text-xs, rendering action errors at roughly 2.5mm on that panel.
- (dispatching) Add single-column layout option
- Split leaves the bottom of the own-calls column idle, since operators usually have one or two active calls. Stacked runs all three sections down one column, trading vertical headroom for full-width cards whose caller identity no longer truncates. Split stays the default.
Queue sections are marked by rules carrying a label and a count. The label stays at text-xs at every density: it is wayfinding for new users, and sitting on the rule costs no vertical space. Own calls get no rule and show "No active calls" instead, which also fills a blank region that split layout left unexplained.
Lists, separators and the dashed placeholder move into their own components so both layouts compose the same pieces. Cards and lists gain shrink-0, without which they collapsed inside a scrolling flex column and painted over the following section.
- (dispatching) Stabilise participated action row positions
- Slot 1 now always holds the state-advance action (Answer, Hold, Resume) so the button under the operator's finger keeps a compatible meaning across ringing to connected. Slots 2 to 4 stay empty during ringing rather than repositioning slot 1, and Hangup sits in slot 5 behind a fixed safety column so the destructive action never neighbours Leave.
Actions moved to a CSS grid defined in participated-bridge-view.module .scss; bridge-card-layout no longer forces a flex row on its actions prop so each view owns its own layout.
🐛 Bug Fixes
- Localize display density settings
🚜 Refactor
- (dispatching) Unify bridge card header layout
- Compact placed the timer beside the state icon and pushed the caller to the right edge, while looser densities gave the caller the middle slot. Fields therefore swapped sides when density changed.
All densities now use one fixed slot order, so only the type scale varies. Compact additionally gains identity truncation and a right-aligned tabular timer.
- (dispatching) Redesign bridge card accents via css module
- Card state was signalled by a 4px coloured left edge, hard to catch in peripheral vision on a 123 DPI panel viewed at an oblique angle. Full borders plus a matching body tint register faster while scanning, without changing the card's height or width.
Colours are derived through the theme's OKLCH l/c rails and selected via a data-accent attribute on the card, so both light and dark surfaces stay in step with the rest of the app and the mapping lives in one place. Waiting reads flatter than the base to keep the queue of unclaimed calls from competing with cards that need action.
Callers now pass a typed accent (participating | attended | waiting) instead of a raw class name, so the waiting case stops being an empty string and the compiler will flag a new state if one is added.
🧪 Testing
- Import unit test functions to prevent false positive syntax errors
[9.25.1] - 2026-07-24
🐛 Bug Fixes
- (email) Add czech localization
[9.25.0] - 2026-07-24
🚀 Features
- (crm) Split settings of automatic crm tab opening to out/in calls
[9.24.0] - 2026-07-24
🚀 Features
- (email) Display email id in test env
- (email) Enhance UX of template menu
🐛 Bug Fixes
- (email) Replace email.edit with email.get API in embeded email viewer
- (email) Save only unsaved email data fields to prevent unassigning the agent when they had the email already opened
[9.23.0] - 2026-07-19
🚀 Features
- (dispatch-bridge) Ring with distinctive sound when there is unattended bridge
[9.22.5] - 2026-07-18
🐛 Bug Fixes
- (dispatching-rooms) Replace mod + fkey with f13/14
[9.22.4] - 2026-07-18
🐛 Bug Fixes
- Remove froala dark mode customization as it made popups transparent
- (email) Make email background always white even in dark mode to make email body consistent
- (dispatching-rooms) Add alternative hotkeys for handset down and handset up events to prevent writing macro into inputs
[9.22.3] - 2026-07-15
🐛 Bug Fixes
- (email) Allow adding template multiple times
[9.22.2] - 2026-07-15
🐛 Bug Fixes
- Do not activate temp tabs if activate is disabled
[9.22.1] - 2026-07-14
🐛 Bug Fixes
- (email) Localize 'replied' email state
[9.22.0] - 2026-07-14
🚀 Features
- (audio) Add silent and loud audio profiles that can be applied using macro
- (uphone) Connect webrtc even when there are connected dispatching bridges
- (dispatch-bridge) Allow user filter which dispatch bridges will be visible to them in ui
- (dispatch-bridge) Replace texts with visual interpretation of empty dispatch bridge
- Add title with user's displayname to person icon
- (dispatch-bridge) Always display name and number of pstn participant even when there is more than 2 participants
- (dispatch-bridge) Display dispatch bridge debug json if enabled in config
- (dispatch-bridge) Add possibility to add workmates to bridge
- (dispatch-bridge) Filter workmates by team so dispatcher sees only users in same team
🐛 Bug Fixes
- (audio) Add czech localization
- (kiosk) Allow conflicting hotkey sequence for handsetup
- Revert work in progress changes
- (dispatching-rooms) Localize dispatch bridges
- (dispatching-rooms) Localize dispatch bridges
- (dispatching-rooms) Localize dispatch bridges
- (email) Switch between send and replied email states when user changes email direction filter
🚜 Refactor
- Remove redundant type annotation
⚙️ Miscellaneous Tasks
- Add unicode flags to dev bundler config regexps
- Make dev proxy work via .env file
- Specify that app should be run with sudo and env file in dev mode
- Fix csp in dev env
[9.21.2] - 2026-07-07
🐛 Bug Fixes
- (email) Address alignment
- (email) Hide inline email attachements conditionally to support former ucs versions
🚜 Refactor
- (outbound) Infer lead type from parser
- (dispatch-bridge) Make dispatching rooms query reusable
[9.21.1] - 2026-07-02
🐛 Bug Fixes
- (outbound) Localize not found text
[9.21.0] - 2026-07-02
🚀 Features
- (template) Add saving status to template editor
🐛 Bug Fixes
- (template) Fix wording
- (email) Make tempalte group and email queue when sending new email searchable
- (outbound) Fix outbound lead parser
[9.20.3] - 2026-07-01
🐛 Bug Fixes
- (email) Fix editor content styles
[9.20.2] - 2026-06-26
🐛 Bug Fixes
- (localization) Remove redundant tests
[9.20.1] - 2026-06-26
🐛 Bug Fixes
- (dispatch-bridge) Localize texts
- Replace ... with …
- (dispatching-rooms) Localize dispatch bridges
- (email) Make email queue widget table horizontally scrollable when it overflows
🚜 Refactor
- (outbound-queue) Replace redux saga with tanstack query
- (outbound-queue) Replace moment with luxon
- (outbound-queue) Add outbound result form typing
- (dispatch-bridge) Remove non-existent css class
- (dispatch-bridge) Centralize actiond dispatching to make sure that user cannot click to actions when previous action did not finish yet
- (dispatch-bridge) Restructure code
🧪 Testing
- Create story for dispatching rooms with action in progress
⚙️ Miscellaneous Tasks
- Repair storybook building
- Create storybook for dispatching rooms
- Fix eslint issues
[9.20.0] - 2026-06-23
🚀 Features
- (uphone) Assume kiosk device to be uphone
🐛 Bug Fixes
- (tasks) Fix displaying crm reference icon in rich task description
[9.19.0] - 2026-06-22
🚀 Features
- (email) Redesign email list widget filter preset buttons and add 2 more options
[9.18.1] - 2026-06-22
🐛 Bug Fixes
- (email) Allow opening external links from email but open them in new tab
⚙️ Miscellaneous Tasks
- Add bunfig.toml with minimum release age
[9.18.0] - 2026-06-17
🚀 Features
- (search) Focus crm search input using hotkey
[9.17.1] - 2026-06-17
🐛 Bug Fixes
- (template) Reload template when attachment is uploaded
[9.17.0] - 2026-06-16
🚀 Features
- (outbound) Remove outbound finish mood reporting
🐛 Bug Fixes
- (tasks) Adjust spacing in embeded email in task history
- (email) Display attachment upload in template editor
- (email) Reload email template when attachment gets deleted to give response to user
[9.16.0] - 2026-06-16
🚀 Features
- (email) Add icons and labels to action history to so changed properties are better readable
🐛 Bug Fixes
- (email) Remove redundant right margin at print button
- (reports) Fix incorrectly styled datetime input
🚜 Refactor
- (email) Replace enabled flag with skipToken
[9.15.1] - 2026-06-15
🐛 Bug Fixes
- (email) Use standard spacing token in email sticky send button family
⚙️ Miscellaneous Tasks
- Reformat build config
- Upgrade rspack to v2
[9.15.0] - 2026-06-12
🚀 Features
- (email) Make cancel/task/save/send buttons in email editor stuck to bottom of window edge to avoid the needness of scrolling down to send email
[9.14.0] - 2026-06-11
🚀 Features
- (supervisor-control) Ux enhancements
🐛 Bug Fixes
- (supervisor-control) Always add/remove/activate/suspend only agents/queues that are currently displayed to agent with respect to applied filters
[9.13.0] - 2026-06-11
🚀 Features
- (supervisor-control) Allow user quickly hide columns by queue type in supervisor control
🐛 Bug Fixes
- (supervisor-control) Prevent crashing the app when user has no permissions to any queue
[9.12.0] - 2026-06-11
🚀 Features
- (email) Allow filtering emails by tag
🐛 Bug Fixes
- (email) Display tags in email queue list widget
[9.11.1] - 2026-06-08
🐛 Bug Fixes
- (email) Remove 'Email detail' text from printed email to save space
[9.11.0] - 2026-06-08
🚀 Features
- (supervisor-control) Make sure that agents are loaded when moving team of agents from one state to another
- (supervisor-control) Add 'hide offline agents' checkbox to filter
- (supervisor-control) Visualy separate filter and profiles
- (supervisor-control) Put filter and profiles to left aside on ultrawide screens
- (supervisor-control) Make whole cell clickable to adjust skill
- (supervisor-control) Highlight row and column to indicate which agent and queue is being assigned
- (supervisor-control) Remove profiles as nobody uses them
🚜 Refactor
- (supervisor-ctrl) Refactor code
- (supervisor-ctrl) Remove dead imports
⚙️ Miscellaneous Tasks
- (supervisor-ctrl) Reformat code
- Remove overflow from dev build
- Exlude analysis data and changelogs from search all
[9.10.0] - 2026-06-08
🚀 Features
- (email) Hide inline attachments (if backend supports attachment inline flag)
- (email) Display attachment icon at subject when email has non-inline attachment
[9.9.0] - 2026-06-05
🚀 Features
- (email) Save email tags right after adding/removing each of them
🐛 Bug Fixes
- (email) Prevent close email button with reason overflowing its containers
- (email) Hide reply buttons texts on medium displays
[9.8.0] - 2026-06-04
🚀 Features
- (email) Allow language filtering via table header
- (uphone) Enable dtmf keyboard in uphone
🐛 Bug Fixes
- (email) Add missing localization
[9.7.1] - 2026-06-02
🐛 Bug Fixes
- (email) Correctly display images in sent emails
- (email) Wait for images to load when printing email
[9.7.0] - 2026-06-01
🚀 Features
- (email) Add header to printed email
[9.6.2] - 2026-06-01
🐛 Bug Fixes
- (email) Prefix email body attachment path to load images inline correctly
[9.6.1] - 2026-06-01
🐛 Bug Fixes
- (localization) Add czech localization of 'Print'
[9.6.0] - 2026-06-01
🚀 Features
- (email) Add print button
🚜 Refactor
- Load lookup email contacts via tanstack query instead of saga
[9.5.0] - 2026-05-30
🚀 Features
- (email) Replace basic input with clearable input in email widget subject and from filter
- (email) Redesign email layout
- (email) Add flag icon to language selector in email
- (email) Separate note with it's own submit button and put back reason to clear what inputs correspond with put back / update buttons
- (email) Save modified email information right after changing the value by introducing edit and save buttons at the field
- (email) Visualy unify static and editable email data
- (email) Make reply all / forward / create task less visualy important
- (email) Make cancel / save / create task less visualy important
- (email) Spacing and rounding polish
🐛 Bug Fixes
- (email) Prevent memory leaks by using srcDoc instead of src with createObjectURL which wasnt released
- (email) Add email 'send again' recipients automatically on blur
- (email) Add missing localization
- (email) Make attachment dropbox background respect dark mode
⚙️ Miscellaneous Tasks
- Add impeccable skill
- (email) Reformat code
[9.4.1] - 2026-05-27
🐛 Bug Fixes
- (email) Show email quick filter in agent mode even if queue is selected in settings
[9.4.0] - 2026-05-27
🚀 Features
- (email) Color email tags in email header data form
[9.3.1] - 2026-05-27
🐛 Bug Fixes
- Add missing localizations
[9.3.0] - 2026-05-27
🚀 Features
- (call-history) Make tables more compact to fit more content
- (design) Separate library and project styles to layers to enahnce styling ergonomy
- (email) Add icons to reply buttons and display text only on large displays
- (email) Hide new task button text on small and medium displays
- (email) Make email header information table more compact
🐛 Bug Fixes
- (wysiwyg-editor) Update license key
- (design) Adjust rounding of table
- (design) Apply full width to only direct child elements instead of all successors in device selection
- (call-history) Make call direction icon cell fixed size to make sure it does not dissapear when there is too much content in the table
- (admin) Remove json rendering in user list
- (admin) Redesing director page in administration
- (email) Add spacing between email person name and crm person badge
- (email) Add email addresses automatically on blur
🚜 Refactor
- (call-history) Refaactor styling of call history filter to remove legacy styling practices in favour of modern approach
- Remove unused breadcrumb SUI styles
- Remove forgotten breadcrumb style files
- Remove unused ad SUI styles
- Remove unused card SUI styles
- Remove unused comment SUI styles
- Remove unused container SUI styles
- Remove unused embed SUI styles
- Remove unused image SUI styles
- Remove unused item SUI styles
- Remove unused nag SUI styles
- Remove unused placeholder SUI styles
- Remove unused progress SUI styles
- Remove unused rail SUI styles
- Remove unused reveal SUI styles
- Remove unused shape SUI styles
- Remove unused sidebar SUI styles
- Remove unused statistic SUI styles
- Remove unused step SUI styles
- Rename incorrectly named variable in email template selector
⚙️ Miscellaneous Tasks
- Reformat code
- Reformat css code
- Ignore tanstack query query key lint rule because there is usually unserializable api instance
[9.2.1] - 2026-05-19
🐛 Bug Fixes
- (queues) Listen for inbound queue update events and sync queue active state
[9.2.0] - 2026-05-18
🚀 Features
- (uphone) Disable device selection inputs when device options are not loaded from browser api yet
- (tabs) Automatically remove unused tabs after long inactivity
🚜 Refactor
- (uphone) Unify auto answer and ring output device setting
[9.1.2] - 2026-05-18
🐛 Bug Fixes
- (lexical) Install prettier as dependency to enable prettier plugin in lexical editor
⚙️ Miscellaneous Tasks
- (uphone) Log whether output/ring/input device change was origined by user interaction or FE API call
- (dev-server) Disable react-refresh for instance config and volumeMeterProcessor
[9.1.1] - 2026-05-15
🐛 Bug Fixes
- (build) Define exact version of otel api library to make sure that patch is applied
[9.1.0] - 2026-05-15
🚀 Features
- (widget-core) Rewrite widget layout edit mode core
- (login) Reintroduce automatic user mobility logout when user logs out via Operator API to keep compatibility with 3rd party applications
🐛 Bug Fixes
- Prevent datetime calendar picker to jump back to current month
⚙️ Miscellaneous Tasks
- (changelog) Setup changelog generating to v9 file
- (changelog) Automate generating changelog separated by major version into files
[9.0.0] - 2026-05-13
🚀 Features
- (login) [breaking] User's paired device is no longer unpaired on logout
🚜 Refactor
- (widget-core) Add typing of widget layout actions
- (widget-core) Remove unsued exception declaration
⚙️ Miscellaneous Tasks
- Reformat code
- (oxlint) Install missing lint plugins
- (oxlint) Remove rule causing oxlint fail on parsing typescript