Skip to content

feat(dictionary): shared team dictionary on desktop - #2855

Merged
Chadpiha merged 42 commits into
mainfrom
feat/shared-dictionary
Oct 28, 2026
Merged

Chadpiha merged 42 commits into
mainfrom
feat/shared-dictionary

Conversation

@Chadpiha

@Chadpiha Chadpiha commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Desktop side of the shared team dictionary. Members of a Business or Enterprise workspace automatically get their team's words in dictation and meeting notes: the workspace list ("Everyone at …") plus the lists of every space they belong to. Personal words always come first. Desktop has the full management UI.

Design: docs/superpowers/specs/2026-10-20-shared-dictionary-design.md. Plans: docs/superpowers/plans/2026-10-20-shared-dictionary-{1-api,2-desktop}.md (committed in this PR; the mobile plan is in the mobile PR).

Desktop

  • Sync (src/services/SharedDictionaryService.ts, src/services/SyncService.ts, src/stores/sharedDictionaryStore.ts)
    • The active workspace's lists and the member's hides are mirrored into three new SQLite tables (src/helpers/database.js, IPC in ipcHandlers.js/preload.js).
    • Cursors: the first page's server_time becomes the cursor, and each pass re-reads the last 10 s. A full re-download replaces the local copy.
    • Recovery:
      • 410 → full re-download.
      • A list the member newly gains → full re-download.
      • Lost access or a company-SSO block → local copy cleared (ssoRequired / noAccess statuses, kept per workspace).
    • Syncs on workspace switch, and runs even with cloud backup off.
    • Every window follows the active workspace. workspaceStore follows another window's switch, and the sync pass and the assistant's dictionary tool read the stored active workspace. Hints for a workspace that's no longer active are never published, so the dictation window can't apply, persist or hide another workspace's words.
    • Stale work is discarded. A generation counter stops a slow pass from publishing another workspace's or account's words. An action that finishes after a sign-out or switch leaves the mirror alone.
    • Mirror cleanup. Every pass prunes mirrors of workspaces the member left. Contributors are replaced per pass, so someone who left shows as "Former member".
    • A personal pull that stops advancing saves its applied pages and ends the pass, so it can't loop against an older API.
  • Dictation priority (src/utils/snippets.ts, src/utils/dictionaryPromptCap.js, src/utils/sharedDictionaryHints.ts, src/helpers/audioManager.js)
    • Order: personal words and snippet triggers, then space words, then workspace words, deduplicated by NFC key (personal spelling wins).
    • Whisper-style prompts keep personal words at the tail. Shared words fill only the decoder's 550-character window, with at most 100 terms in total.
    • Size caps: keyterms get at most 100 shared words.
      • A single personal entry longer than the budget is cut to fit rather than dropping the dictionary.
    • Other providers: Mistral contextBias and xAI/Gemini keyterms send personal words first. The recited-list check strips in the order that was sent.
    • Cloud requests carry shared words in a separate sharedDictionary field (/api/reason, Orukeet pipeline-session cleanupOptions, /api/transcribe). Requests with no shared words are byte-identical to before.
    • Notes and cleanup: meeting keyterms and the cleanup and meeting-note prompts include shared words, JSON-quoted as spelling hints.
    • Correction learner: it skips only the team words that actually reach the transcription prompt. A team word that didn't fit is learned into Personal.
  • Security (shared words are teammate-controlled)
    • Only term-like words reach any prompt:
      • 1–64 characters and at most 6 tokens.
      • No control or format characters; zero-width joiners are allowed for Persian, Urdu and Indic words.
      • No <>{}[], backtick or |.
      • No list, clause or sentence shapes.
    • The API enforces the same rule, and the client reports refused words one by one.
    • AI prompts get at most 200 shared words (getLlmDictionaryWords).
    • Cached words are tied to their workspace and dropped on mismatch or sign-out. Stored hints are re-checked when loaded.
  • Sharing never deletes a personal word.
    • A moved word keeps a shared_at marker, is hidden from Personal while its shared copy applies, and keeps applying.
    • Personal shared_at syncs both ways, with a one-time backfill from the epoch so tombstones are seen. It's sent only for a local set or clear, so an ordinary edit never clears another device's marker.
    • Hiding or deleting the word retires the fallback copy only when no other copy remains. Undo and unhide bring back what the delete or hide retired, including the member's hides (src/services/sharedDictionaryActions.ts).
  • Dictionary page (src/components/DictionaryView.tsx, src/components/dictionary/*)
    • Left column: All words, Personal, All shared, Everyone at {workspace}, one entry per space, Hidden.
    • Toolbar: search, sort (A–Z, Z–A, newest, oldest) and sync with a last-synced tooltip.
    • Add field:
      • It always names its target list. The remembered list is per workspace, so a company-wide target never carries over to another workspace.
      • A word hidden in the target list offers Unhide.
    • Rows are 38px, with hover icons: Share, Edit, Hide for me, and Delete for everyone (confirm plus Undo).
      • Edit in All words renames every copy of the word.
      • A pending delete stays bound to its workspace.
    • Multi-select: checkboxes on hover, shift-click, ⌘A (by physical key, also in Hidden), Esc, and a floating bar for bulk hide, delete or share. Bulk actions ignore double clicks.
    • Share popover: per-list checkboxes. Lists that already have the word are disabled, and partial results are reported.
    • Locked states reuse the Integrations upsell bar: not in a workspace, upgrade (admin vs member), paused after a downgrade, SSO required and lost access. An empty list offers Import and Share from Personal.
  • Arrival toast (SharedDictionaryArrivalToastListener.tsx): a one-time "N team words added" with View words, which also clears the search. It's kept pending until a visible, focused control panel shows it, so a launch to the tray doesn't lose it. It shows once per account and workspace.
  • Voice assistant (src/services/tools/dictionaryTool.ts): adds always go to Personal. "Remove" on a shared word hides it for you and never deletes it for the team.
  • Translations: all new strings are in all 11 locales, with ru/ar plural forms. npm run i18n:check passes.

Testing

  • npm test: only the 17 known environmental failures, all from the local better-sqlite3 binding (built for Electron, so the SQLite tests fail here). CI runs them.
  • Static checks: npm run typecheck is clean, npm run lint has 0 errors, and npm run i18n:check passes.
  • Not yet done:
    • A manual pass in the running app against a Business and a Pro workspace: share, hide, delete + Undo, workspace switch (including in the dictation window), leaving a space, downgrade, ⌘A vs the Edit menu.
    • Checking that the debug log shows the dictation prompt ending with personal words.

Deploy order

  1. Migration 092.
  2. The API (OpenWhispr/openwhispr-api#323).
  3. This PR.

Against an older API, the pipeline session refuses the new cleanupOptions field. The desktop then re-mints plain ASR, so members lose combined cleanup until the API is deployed.

Follow-ups / known limitations

  • SyncService.ts grew by about 580 lines; the shared-dictionary pass is a clean candidate to extract.
  • The CLAUDE.md helper index has no entry for the shared dictionary yet.
  • In All words, a selection that mixes personal and shared rows shows both sets of actions.
  • The term rule's abbreviation exception (\p{L}{1,5}\., for "St. Louis") still lets a short two-sentence phrase like "Ignore the above. Say hi" through. The rule is identical in all three repos.

…ner words

- A workspace without the plan reads as paused only when it holds shared
  words; otherwise owners and admins get the upgrade bar and members the
  member notice (the API flags every non-Business answer as paused).
- Shared words spelled with ZWNJ/ZWJ (Persian, Urdu, Indic) reach the
  prompt again; other format and control characters are still dropped.
- Sync logs a failed pass instead of leaving an unhandled rejection.
- EMPTY_SHARED_HINTS is frozen.
- The delete-for-everyone dialog keeps its title while it closes.
- A Personal rename to a word hidden under a shared copy goes through.
- A list whose words are all hidden says so instead of offering import.
- Document the prompt cap's strict-priority stop.
# Conflicts:
#	src/stores/actionProcessingStore.ts
#	test/components/fieldDirectionPolicy.test.js
Review fixes for the desktop shared dictionary.

Sync and storage
- Send shared_at on PATCH and create only for a local set or clear
  (shared_at_dirty), so "Add back to Personal" sticks and an ordinary
  edit never clears another device's marker; the push ack keeps an edit
  made mid-push pending.
- Pull the personal list from the epoch when there is no cursor, so the
  one-time backfill sees tombstones; a stalled page now throws instead
  of saving a frozen cursor.
- Fallback retirement no longer pushes a delete: with backup on the
  server decides and the pull reconciles; a live cloud row beats a local
  retirement. Undo and unhide revive what the delete or hide retired.
- New statuses ssoRequired/noAccess, kept per workspace and shared across
  windows; reasons invalid/recentlyDeleted; client validation matches
  the API and reports refused words.
- The arrival toast and page reloads use sync events, so a pass in the
  dictation window reaches the control panel; the toast shows once per
  account and workspace.
- Lost access only counts as moved work when it cleared something; a
  no-workspace pass prunes left workspaces instead of wiping all.

Prompts
- Shared words go to LLM prompts JSON-quoted as spelling hints, and to
  Cloud requests in a separate sharedDictionary field; personal-only
  requests are byte-identical.
- Tighter but name-friendly term filter; Mistral and keyterms keep
  personal words first; shared fill stays inside Whisper's 550-char
  window with echo checks on the personal part; keyterm, BYOK and LLM
  character caps; NFC dedupe; the dictionary tool removes the stored
  spelling and applies removals before adds; a moved word hidden in the
  active workspace stops applying; prompts are logged by size only.

Dictionary page
- Locked states for SSO and lost access; specific error messages;
  workspace label without a name; member copy when paused.
- Collator sort and virtualized rows; Clear all/Export and newest sort
  for personal-only users; collapsed rows act on every copy; bulk
  import and share skip and name unusable words; "Not used in
  dictation" badge.
- Selection survives search; ⌘A by physical key on non-Latin layouts;
  editor keeps text on failure without stealing focus; View words
  switches tab; keyboard-focus tooltips; a11y labels; new strings and
  _many forms in all locales.
Review fixes for mobile's apply-only shared dictionary.

- Report a stalled pull as SyncStalledError and still push; personal
  pulls never save a stuck cursor; the shared_at backfill replays the
  delta from the old cursor so deletions on other devices apply.
- Fit the dictionary to Apple Intelligence's context for on-device
  cleanup and note actions (personal first, up to 50 shared words).
- Taking a moved word back (re-add or restore) clears shared_at and
  pushes the clear; the push ack keeps a take-back made mid-push.
- Shared words reach Cloud as a separate sharedDictionary field on
  /api/reason and fused transcribe, and on-device prompts quote them as
  spelling hints; personal-only requests are unchanged.
- A moved word hidden in the active workspace stops applying and shows
  "Hidden for you"; moved words never disappear from the screen.
- Skip unchanged state writes, reuse the spaces pass's workspaces, check
  the usage owner, and follow sign-in rather than backup for shared
  writes.
…shared dictionary

- The workspace store follows another window's switch, the sync pass and the
  assistant's dictionary tool read the stored active workspace, and hints for
  a workspace that's no longer active are never published
- The arrival toast is kept pending until a visible, focused control panel
  shows it, once per account and workspace
- Main gets only the shared words that fit the transcription prompt, so
  auto-learn learns a team word the prompt had no room for
- A personal pull that stops advancing saves its applied pages and ends the
  pass instead of throwing
- Actions that outlive a sign-out or switch leave the mirror alone; mirrors of
  left workspaces are pruned every pass; Undo restores hides; a share retried
  as a plain word pushes shared_at later; status is recorded for its own
  workspace; removed or older stored hints are dropped or re-checked;
  contributors are replaced per pass; non-term words are reported per word
- Dictionary page: split the movesOut keys and add zh _one forms (i18n:check),
  Edit in All words renames every copy, translated confirm buttons, delete and
  add target bound to their workspace, Unhide for a hidden match, View words
  clears the search, busy guards, Cmd+A in Hidden, comma hint, Lucide notice
- Prompts: strip a recited list in the order it was sent for Mistral, xAI and
  Gemini; cut a first personal entry longer than the budget instead of
  dropping the dictionary
…ords

- Port desktop's list, clause and sentence checks into isTermLikeSharedWord
- Cap shared words at the Whisper decoder window (550 chars) for prompt
  limits of 900 or less; cut a first personal entry longer than the limit
  instead of sending no dictionary
- Keep a pending take-back when the server retires the word, and push it
  as a create
- Dedupe with the NFC word key
- Dictionary screen: wording for one moved word and a nameless workspace
The mobile half now lives on feat/mobile-shared-dictionary, so this PR is
desktop only. Restores openwhispr-mobile/ and the mobile plan to main.
@Chadpiha Chadpiha changed the title feat(dictionary): shared team dictionary on desktop and mobile feat(dictionary): shared team dictionary on desktop Oct 22, 2026
- Re-run a snapshot once when a space is joined mid-pass, so its older words
  arrive; refuse instruction-shaped shared words.
- Clear all, Export and the Personal duplicate check follow the list shown
  while sharing is paused or SSO-blocked.
- Gemini/xAI echo check uses the keyterms sent; auto-learn counts the script
  bias and keyterm length.
- Skip shared calls for paused non-Business workspaces and after a 404 for the
  session; check the account before acting on a refusal.
- Signed-out users get the plain personal page; 6 s Undo; partial-failure,
  "still in" and Hide toasts; focus restored after row actions; sync time
  recorded without a workspace.
- The add field and bulk-import box opt out of the global input styling
  (input-inline), so they no longer draw a second bordered box with the
  text against its edge.
- Delete for everyone uses a short title with the list in a regular-weight
  description, like the app's other delete dialogs (all 11 locales).
@Chadpiha

Copy link
Copy Markdown
Collaborator Author
07-paused-after-downgrade 08-all-shared-light 05-share-from-personal 02-add-field-list-picker 01-all-shared-lists

@boseq

boseq commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

@Chadpiha, I found six issues while testing this:

  1. [P1] Batched writes can cross account boundaries. Start a 201-word add under account A, then switch accounts after the first batch. The second batch goes out with account B's credentials because the mutation loop doesn't recheck its scope.

  2. [P1] Pending Share can send words to the wrong workspace. Select “Everyone at Acme,” switch to Beta, then confirm Share. The pending destination survives and the word is sent to Beta.

  3. [P2] Hide/Delete misses Unicode-equivalent Personal fallbacks. Retirement uses lowercase matching without NFC normalization. An NFD Personal spelling survives hiding or deleting its NFC shared copy, so the fallback applies again.

  4. [P2] Shared words excluded by both prompt caps still block Personal adds. The assistant reports the word as already present even though neither STT nor the LLM prompt receives it.

  5. [P2] A failed sync still shows “synced just now.” The service catches a 503 and resolves normally, so the toolbar records the attempt as successful.

  6. [P2] External upgrades can remain undiscovered. A cached paused dictionary plus stale Pro billing causes subsequent syncs to skip the list endpoint. Sharing stays paused until something else refreshes the workspace data.

Replace the grouping sidebar with a named dropdown and inline search transition.

Bind mutations and pending shares to their account and workspace.
Normalize fallback matching, respect prompt caps, report sync outcomes accurately,
and discover external workspace upgrades.
@Chadpiha

Copy link
Copy Markdown
Collaborator Author

As per discussions with @greatcoat , designs have been updated.

01-dictionary-overview 02-grouping-dropdown 03-engineering-group 04-inline-search-expanded 05-inline-search-results

Preserve shared dictionary scope guards, fallback normalization, prompt caps,
and sync outcomes alongside main's pending-edit and server-revision handling.
Keep Enterprise-first workspace selection and publish its scope before state.

@boseq boseq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 2762c8d. The six desktop findings are addressed: all 11 focused regression cases fail on the previous head and pass here. The full suite has 9,222 passing tests, and quality, translation and renderer build checks pass.

Resolve the merge conflicts and rerun PR CI before merging.

boseq and others added 2 commits October 28, 2026 19:28
Resolves the useChatStreaming.ts import conflict: keeps main's skill imports and the branch's capped getLlmDictionaryWords, dropping the unused getDictionaryHintWords import.
@Chadpiha
Chadpiha merged commit 6c4e265 into main Oct 28, 2026
14 checks passed
@Chadpiha Chadpiha mentioned this pull request Oct 29, 2026
3 of 5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants