From 72181e49bd4b97f03e0e719ec6664832e2e2267c Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:30:57 +0200 Subject: [PATCH] feat(nav): one-click company switching at the top of the sidebar (#1664) (#1765) Company switching had moved into a nested flyout in the bottom-of-sidebar user popover: avatar, then Byt foretag, then the company. Three clicks per switch is painful for consultants who hop between companies constantly. Mount the existing one-click CompanySwitcher (already live in the mobile sheet, same performCompanySwitch path) at the top of the expanded desktop sidebar, pinned above the nav scroll container and outside the data-ph-unmask navs so the company name stays masked in replays. The user-menu flyout remains as the secondary path; the collapsed 64px rail keeps switching via the UserMenu avatar. Also label the brand logo link with a native title tooltip so it is not an unlabeled square. Pinned by a source-shape regression test, the same pattern as the JournalEntryList copy affordance: the repo does not render components in tests. Closes #1664 Signed-off-by: Emil Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + components/dashboard/DashboardNav.tsx | 17 +++++ .../sidebar-company-switcher.test.ts | 69 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 components/dashboard/__tests__/sidebar-company-switcher.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 7a0faee3..920e6b70 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1131,3 +1131,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] The single-call /chat assistant answers over a bounded READ-ONLY tool loop (audit Option A: "single-call actions over the existing MCP tool functions"), plus an always-on company snapshot as the backstop: #1759 shipped a version that read only the company name/entity, so it answered "jag har ingen bokföringsdata" to every figures question. Rather than re-introduce the ripped streaming Anthropic runtime, the provider-agnostic lib/ai generateText gained optional `tools`/`maxSteps`: the OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs) which drives the loop, and the Anthropic-family service hand-rolls a small loop against messages.create (kept on the raw Anthropic SDK so no new deps and hosted stays byte-identical for every non-tool caller). ask-service attaches ONLY the read slice of general.help's tool whitelist via agentToolRegistry (write/staging + memory-write tools excluded; readOnlyHint/destructiveHint re-checked), dispatched with the same agent_chat actor run-turn uses. Works on Bedrock and on any local model with function-calling (Qwen); a text-only model still answers status questions from the snapshot (company_settings + deadlines, never figures). Not chosen: deterministic-context-only (bounded coverage) and unifying both providers on the AI SDK (would need @ai-sdk/anthropic + @ai-sdk/amazon-bedrock deps and change the hosted path). [2026-08-19] undo_bank_file_import (#1672) skips unbooked rows carrying payment_match_log history instead of weakening the audit_log_immutable delete guard: the log is append-only räkenskapsinformation (BFL 7 kap) per 20260323120000 ("Do NOT add cleanup/DELETE jobs") and the single-row DELETE route already refuses those rows (TRANSACTION_DELETE_HAS_AUDIT_TRAIL); the undo reports skipped_match_history so the user can ignore the stragglers. Rejected: a scoped trigger bypass like the GDPR account-delete RPC uses (erasure is a legal right overriding retention; an import undo is not). [2026-08-19] transactions.bank_file_import_id has NO retroactive backfill: attribution by (format, date window) can mislink rows to the wrong batch when a company has several same-format imports, and "undo this import" must never delete rows from a different one. Imports executed before 20260820071500 are simply not undoable through this action. +[2026-08-19] Sidebar company switch (#1664): reinstated the existing CompanySwitcher at the top of the expanded desktop sidebar instead of turning the logo slot into the switcher (the issue offered both); the logo keeps its home link plus a native title tooltip, the user-menu flyout stays as the secondary path, and the collapsed rail relies on the UserMenu avatar since 64px has no room for a labeled switcher. diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 79b97793..b0bc52bf 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -706,9 +706,13 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa : 'justify-between pl-5 pr-3', )} > + {/* Native title tooltip (same convention as the collapsed rail + items) so the logo square is labeled on hover, not a mystery + mark (#1664). */} + {/* One-click company switch pinned at the top of the sidebar + (#1664): consultants hop between companies constantly, and the + user-menu flyout costs three clicks. That flyout stays as the + secondary path. Hidden on the collapsed rail (no room at 64px); + the UserMenu avatar still reaches the same switch there. Kept + outside the data-ph-unmask navs so the company name stays + masked in replays, like the mobile sheet's switcher. */} + {!collapsed && ( +
+ +
+ )} + {/* Nav items in their own scroll container so the user block below stays sticky (concept PR 2). */}
diff --git a/components/dashboard/__tests__/sidebar-company-switcher.test.ts b/components/dashboard/__tests__/sidebar-company-switcher.test.ts new file mode 100644 index 00000000..b0604eff --- /dev/null +++ b/components/dashboard/__tests__/sidebar-company-switcher.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +const NAV_SRC = fs.readFileSync( + path.resolve(__dirname, '../DashboardNav.tsx'), + 'utf8', +) +const USER_MENU_SRC = fs.readFileSync( + path.resolve(__dirname, '../UserMenu.tsx'), + 'utf8', +) + +/** + * Regression pin for one-click company switching in the sidebar (#1664). + * + * The switch was folded into the bottom-of-sidebar user popover as a nested + * flyout (avatar, then "Byt foretag", then the company): three clicks per + * switch, which consultants who hop between companies do constantly. The + * one-click CompanySwitcher must stay mounted at the top of the desktop + * sidebar, with the user-menu flyout as the secondary path. The repo does + * not render components in tests, so pin the source shape instead, the same + * way JournalEntryList's copy affordance is pinned. + */ +describe('DashboardNav sidebar company switcher (#1664)', () => { + const asideStart = NAV_SRC.indexOf(' { + expect(asideStart).toBeGreaterThan(-1) + expect(mobileNavStart).toBeGreaterThan(asideStart) + expect(desktopScrollStart).toBeGreaterThan(asideStart) + expect(desktopScrollStart).toBeLessThan(mobileNavStart) + }) + + it('renders CompanySwitcher inside the desktop sidebar, not only the mobile sheet', () => { + const desktopSwitcher = NAV_SRC.indexOf('', asideStart) + expect(desktopSwitcher).toBeGreaterThan(asideStart) + expect(desktopSwitcher).toBeLessThan(mobileNavStart) + }) + + it('pins the desktop switcher to the top of the sidebar, above the nav scroll container', () => { + const desktopSwitcher = NAV_SRC.indexOf('', asideStart) + expect(desktopSwitcher).toBeLessThan(desktopScrollStart) + }) + + it('keeps the mobile sheet switcher as well', () => { + const desktopSwitcher = NAV_SRC.indexOf('', asideStart) + const mobileSwitcher = NAV_SRC.indexOf('', mobileNavStart) + expect(mobileSwitcher).toBeGreaterThan(desktopSwitcher) + }) + + it('labels the brand logo link with a visible title tooltip and aria-label', () => { + const logoLink = NAV_SRC.slice( + NAV_SRC.indexOf('', asideStart), + ) + expect(logoLink).toContain('aria-label={getBranding().appName}') + expect(logoLink).toContain('title={getBranding().appName}') + }) + + it('keeps the user-menu company flyout as the secondary switch path', () => { + expect(USER_MENU_SRC).toContain('performCompanySwitch') + expect(USER_MENU_SRC).toContain('setCompaniesOpen') + }) +})