* feat(assistant): dock the panel into the frame and give the trigger a status channel Two things the panel could not do. It covered the page it was talking about. Opening it on /invoices laid a 480px curtain over the invoice, so verifying an answer meant closing the thing that gave it. The page panel now gives up that width plus the frame's own gutter, and the two float side by side. Docking applies at the compact width only: expanded is a deliberate focus mode, where there is no page left to read anyway, so it goes back to overlaying. Driven by a --agent-dock-w custom property because the frame layout is a server component; globals.css seeds the default so the first paint is not a jump, and below md nothing changes. And a minimized session was silent. The agent could be three tool calls into a booking, or finished ten minutes ago, and the pill said "Fortsätt med Anna" either way, so the only way to find out was to reopen it. There is now one status channel: the trigger spins with the current step while work runs and shows an unread dot when a turn landed behind a hidden panel. The channel is a reducer in a React-free module rather than a pair of booleans, because a durable background run has to publish to the same one later. Its 'detached' state (working somewhere the user cannot see) is built and rendered now even though nothing dispatches it in v1, so adding runs is a publisher and not a redesign. Turn boundaries derive from the streaming flag rather than being published per call site: a turn can end by completing, erroring, aborting or being stopped, and missing one would leave the trigger claiming the agent is still working forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the Sonnet 5 ceiling, dock and status-channel decisions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
import { redirect } from 'next/navigation'
|
|
import { headers } from 'next/headers'
|
|
import DashboardNav from '@/components/dashboard/DashboardNav'
|
|
import { MainContainer } from '@/components/dashboard/MainContainer'
|
|
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
|
|
import { RecaptIdentify } from '@/components/RecaptIdentify'
|
|
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
|
|
import AgentTrigger from '@/components/agent/AgentTrigger'
|
|
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
|
|
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
|
|
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
|
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
|
import { CompanyProvider } from '@/contexts/CompanyContext'
|
|
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
|
import { getBranding } from '@/lib/branding/service'
|
|
import type { EntityType, CompanyRole, Team } from '@/types'
|
|
import {
|
|
getDashboardAuthContext,
|
|
getDashboardCompanyId,
|
|
getDashboardSettings,
|
|
getResolvedDashboardAgentProfile,
|
|
} from './request-context'
|
|
|
|
/**
|
|
* Routes inside the dashboard group that must remain reachable when the
|
|
* user has no active company. Keep in sync with the middleware's
|
|
* no-company allowlist.
|
|
*/
|
|
const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
|
|
|
|
/**
|
|
* Frame layout: on desktop the page is a rounded panel floating on the
|
|
* warm-toned frame (bg-frame on the wrapper div), with its own inner
|
|
* scroll. 10px margin against the frame; height is the remaining
|
|
* viewport. The sidebar (fixed, w-64) sits borderless on the frame, so
|
|
* the panel starts at ml-64. On mobile the panel dissolves: full-width
|
|
* document flow with the bottom nav, exactly as before.
|
|
*/
|
|
const MAIN_PANEL_CLASS =
|
|
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
|
|
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[var(--agent-dock-w)] md:h-[calc(100vh-20px)] ' +
|
|
'md:overflow-y-auto md:rounded-xl md:border md:border-border ' +
|
|
'md:transition-[margin-left,margin-right] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]'
|
|
|
|
export default async function DashboardLayout({
|
|
children,
|
|
settingsModal,
|
|
}: {
|
|
children: React.ReactNode
|
|
// `@settingsModal` parallel slot: renders the routed settings modal over the
|
|
// current page on in-app navigation to /settings/*; null otherwise.
|
|
settingsModal: React.ReactNode
|
|
}) {
|
|
const { supabase, user } = await getDashboardAuthContext()
|
|
|
|
if (!user) {
|
|
redirect('/login')
|
|
}
|
|
|
|
// Resolve active company from user_preferences (authoritative). The
|
|
// `gnubok-company-id` cookie is intentionally no longer consulted here:
|
|
// `getActiveCompanyId` reads from user_preferences, matching what RLS
|
|
// sees via `current_active_company_id()`. Keeping both sides on the same
|
|
// source avoids cross-tab / cookie divergence.
|
|
// Team membership (with the team row embedded) only depends on user.id,
|
|
// so it resolves in parallel, this layout is on the critical path of
|
|
// every dashboard page, so sequential round-trips are wall-clock time.
|
|
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([
|
|
getDashboardCompanyId(),
|
|
// Read the pathname forwarded by middleware so we can branch on it.
|
|
headers(),
|
|
supabase
|
|
.from('team_members')
|
|
.select('team_id, role, teams:team_id(*)')
|
|
.eq('user_id', user.id)
|
|
.limit(1)
|
|
.maybeSingle(),
|
|
])
|
|
|
|
const pathname = headerStore.get('x-pathname') ?? ''
|
|
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
|
|
pathname.startsWith(p)
|
|
)
|
|
|
|
const team: Team | null =
|
|
(teamMembership?.teams as unknown as Team | null) ?? null
|
|
const isTeamMember = !!teamMembership
|
|
|
|
// No companies: redirect to onboarding, except for allowed escape-hatch
|
|
// routes (so the user can still reach /settings/account to delete their
|
|
// account after archiving their last company).
|
|
if (!companyId) {
|
|
if (!isNoCompanyAllowed) {
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
return (
|
|
<CompanyProvider
|
|
value={{
|
|
company: null,
|
|
role: null,
|
|
companies: [],
|
|
isTeamMember,
|
|
team,
|
|
isSandbox: false,
|
|
capabilities: [],
|
|
trialEndsAt: null,
|
|
}}
|
|
>
|
|
<AgentSheetProvider>
|
|
<CompanyTabSync />
|
|
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
|
<DashboardNav
|
|
companyName={getBranding().appName.toLowerCase()}
|
|
entityType="enskild_firma"
|
|
isSandbox={false}
|
|
extensionNavItems={getExtensionNavItems()}
|
|
/>
|
|
<main
|
|
id="main-content"
|
|
className={MAIN_PANEL_CLASS}
|
|
role="main"
|
|
>
|
|
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
{settingsModal}
|
|
<SettingsHotkey />
|
|
</div>
|
|
</AgentSheetProvider>
|
|
</CompanyProvider>
|
|
)
|
|
}
|
|
|
|
// Fetch company + membership for context provider, together with the
|
|
// nav/badge data, none of these depend on each other, only on
|
|
// companyId/user.id, so one round-trip batch instead of two. The rare
|
|
// stale-cookie early return below wastes the extra reads; that's cheaper
|
|
// than serializing two batches on every dashboard render.
|
|
const [
|
|
{ data: companyRow },
|
|
{ data: memberRow },
|
|
{ data: allMemberships },
|
|
{ data: settings },
|
|
agentProfileIdentity,
|
|
{ data: userProfile },
|
|
entitlements,
|
|
{ data: allSettingsNames },
|
|
{ data: userPrefs },
|
|
] = await Promise.all([
|
|
supabase.from('companies').select('*').eq('id', companyId).single(),
|
|
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
|
|
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
|
|
getDashboardSettings(),
|
|
// Nav badge counts (unbooked transactions, pending operations) are NOT
|
|
// fetched here anymore: DashboardNav loads them client-side after mount
|
|
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
|
|
// first paint on every dashboard navigation.
|
|
// Agent identity, name + avatar, surfaced on the FAB and chat
|
|
// surfaces. Null when no agent_profile exists yet (banner CTA path).
|
|
getResolvedDashboardAgentProfile(),
|
|
// The signed-in user's profile, shown in the bottom-left account
|
|
// popover (full_name + initial) so it's clear which user is logged
|
|
// in, distinct from the active company shown at the top.
|
|
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
|
getCompanyEntitlements(supabase, companyId),
|
|
// Current display names for ALL the user's companies (the switcher list).
|
|
// RLS scopes company_settings SELECT to user_company_ids(), so this bare
|
|
// select returns exactly the caller's companies, letting non-active rows
|
|
// show company_settings.company_name instead of the frozen companies.name.
|
|
supabase.from('company_settings').select('company_id, company_name'),
|
|
// Per-user UI state (nav collapse/fold state), server-rendered so the
|
|
// sidebar width is right on first paint, plus the hide-assistant-FAB
|
|
// preference (Inställningar → Assistenten). Batched here so it costs no
|
|
// extra round-trip on the dashboard critical path.
|
|
supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(),
|
|
])
|
|
|
|
// company_id -> current display name for every company the user belongs to.
|
|
const nameByCompany = new Map(
|
|
(allSettingsNames || []).map((s) => [s.company_id, s.company_name as string | null]),
|
|
)
|
|
|
|
if (!companyRow || !memberRow) {
|
|
// Stale cookie pointing to a deleted/inaccessible company.
|
|
// Render the empty-state dashboard so user can switch or create a company.
|
|
const companyContextValue = {
|
|
company: null,
|
|
role: null,
|
|
companies: (allMemberships || []).filter(m => m.companies).map((m) => {
|
|
const c = m.companies as unknown as import('@/types').Company
|
|
return {
|
|
company: { ...c, name: nameByCompany.get(c.id) || c.name },
|
|
role: m.role as CompanyRole,
|
|
}
|
|
}),
|
|
isTeamMember,
|
|
team,
|
|
isSandbox: false,
|
|
capabilities: [],
|
|
trialEndsAt: null,
|
|
}
|
|
|
|
return (
|
|
<CompanyProvider value={companyContextValue}>
|
|
<AgentSheetProvider>
|
|
<CompanyTabSync />
|
|
<div className="min-h-screen bg-frame md:flex md:flex-col">
|
|
<DashboardNav
|
|
companyName={getBranding().appName.toLowerCase()}
|
|
entityType="enskild_firma"
|
|
isSandbox={false}
|
|
extensionNavItems={getExtensionNavItems()}
|
|
/>
|
|
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
|
|
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
{settingsModal}
|
|
<SettingsHotkey />
|
|
</div>
|
|
</AgentSheetProvider>
|
|
</CompanyProvider>
|
|
)
|
|
}
|
|
|
|
// If onboarding incomplete, still render the dashboard: the page component
|
|
// will show the inline onboarding card instead of the normal dashboard content.
|
|
|
|
// Use company_name from settings as the display name (companies.name may be stale)
|
|
const displayName = settings?.company_name || companyRow.name
|
|
|
|
// Resolve entity type the same way the report engines and
|
|
// getCompanyEntityType do: company_settings is read-primary, companies is the
|
|
// canonical fallback, then default to enskild_firma. Mirroring it onto the
|
|
// active company keeps the settings rail (useSettingsNavItems, which reads
|
|
// context) and the sidebar in agreement on who is an employer. #782
|
|
const entityType =
|
|
(settings?.entity_type as EntityType) ||
|
|
(companyRow.entity_type as EntityType) ||
|
|
'enskild_firma'
|
|
const paysSalaries = settings?.pays_salaries ?? false
|
|
// Dimensions register visibility (Kostnadsställen & projekt nav row). Same
|
|
// mechanism as paysSalaries: UI gate only, never load-bearing for
|
|
// correctness (dimensions plan §2).
|
|
const dimensionsEnabled = settings?.dimensions_enabled ?? false
|
|
const companyWithName = {
|
|
...companyRow,
|
|
name: displayName,
|
|
entity_type: entityType,
|
|
pays_salaries: paysSalaries,
|
|
}
|
|
|
|
const isSandbox = settings?.is_sandbox === true
|
|
|
|
// Client-driven UI preferences (sidebar collapse + fold state). Read here
|
|
// so the shell renders at the right width on first paint; the nav toggles
|
|
// flip the data attribute client-side and persist via /api/user/ui-state.
|
|
const uiState = (userPrefs?.ui_state ?? {}) as import('@/types').UserUiState
|
|
const navCollapsed = uiState.nav_collapsed === true
|
|
|
|
const companyContextValue = {
|
|
company: companyWithName,
|
|
role: memberRow.role as CompanyRole,
|
|
companies: (allMemberships || []).map((m) => {
|
|
const c = m.companies as unknown as import('@/types').Company
|
|
// Current display name for every company (company_settings.company_name,
|
|
// falling back to the frozen companies.name) so non-active switcher rows
|
|
// are current too. For the active company this equals `displayName`.
|
|
return {
|
|
company: { ...c, name: nameByCompany.get(c.id) || c.name },
|
|
role: m.role as CompanyRole,
|
|
}
|
|
}),
|
|
isTeamMember,
|
|
team,
|
|
isSandbox,
|
|
capabilities: entitlements.capabilities,
|
|
trialEndsAt: entitlements.trialEndsAt,
|
|
}
|
|
|
|
return (
|
|
<CompanyProvider value={companyContextValue}>
|
|
<AgentSheetProvider
|
|
identity={{
|
|
displayName: agentProfileIdentity?.display_name ?? null,
|
|
avatarId: agentProfileIdentity?.avatar_id ?? null,
|
|
isVerified: Boolean(agentProfileIdentity?.verified_at),
|
|
}}
|
|
>
|
|
<CompanyTabSync />
|
|
<div
|
|
id="dash-shell"
|
|
className="min-h-screen bg-frame md:flex md:flex-col"
|
|
style={{ '--nav-w': navCollapsed ? '64px' : '248px' } as React.CSSProperties}
|
|
>
|
|
{/* Skip to content link for keyboard/screen reader users */}
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
|
>
|
|
Hoppa till innehåll
|
|
</a>
|
|
{isSandbox && <SandboxBanner />}
|
|
<DashboardNav
|
|
companyName={settings?.company_name || 'Min verksamhet'}
|
|
entityType={entityType}
|
|
paysSalaries={paysSalaries}
|
|
dimensionsEnabled={dimensionsEnabled}
|
|
isSandbox={isSandbox}
|
|
extensionNavItems={getExtensionNavItems()}
|
|
userName={userProfile?.full_name ?? null}
|
|
userEmail={user.email ?? null}
|
|
initialUiState={uiState}
|
|
/>
|
|
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
|
|
<MainContainer companyId={companyId}>{children}</MainContainer>
|
|
</main>
|
|
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
|
|
<LazyCommandPalette />
|
|
<SettingsHotkey />
|
|
{settingsModal}
|
|
</div>
|
|
{!isSandbox && (
|
|
<RecaptIdentify
|
|
userId={user.id}
|
|
email={user.email}
|
|
displayName={settings?.company_name || undefined}
|
|
/>
|
|
)}
|
|
</AgentSheetProvider>
|
|
</CompanyProvider>
|
|
)
|
|
}
|