Files
9cb1d105e3 fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them (#2204) (#2343)
* fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them

Self-hosted deployments on an OpenAI-compatible provider (or with no AI
configured) still showed every entry point into the tool-loop runtime
behind /api/agent/invoke, which answers 503 there. The capability lived
server-side only (getAiStatus().assistantAvailable); no UI could read it.

Hand the flag to the client through CompanyContext (useAssistantAvailable,
beside the paid-capability gate) and gate each entry point that opens
AgentChat: the bookkeeping page's "Skapa med assistent" and "Med
assistenten", the inbox workspace's "Fråga assistenten" doors, /chat/intake
and /chat/new?intent=. The floating trigger falls back to general help (the
single-call console runs on any provider) instead of hiding, and AgentChat
itself never fires an invoke without the runtime, so a resumed thread or a
forgotten entry point shows a notice instead of a 503. The Hem checklist's
"Anslut till Claude" step renders only where the assistant runs on Claude
and the mcp-server extension is on. Provider-agnostic AI (ask console,
categorization, extraction) and the server-side 503 are unchanged; on
hosted the flag is true and nothing changes.

Closes #2204

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* test(ai): use a placeholder that cannot match an Anthropic key shape

The new direct-Anthropic status test assigned a string in the exact
format of a live API key, which trips secret scanners on every run. The
config only reads presence, so any non-empty string exercises the path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:54:47 +02:00

137 lines
5.0 KiB
TypeScript

'use client'
import { createContext, useContext } from 'react'
import type { Company, CompanyRole, Team } from '@/types'
import type { CapabilityKey } from '@/lib/entitlements/keys'
import type { EntitlementState } from '@/lib/entitlements/has-capability'
import type { MultiUserAccess } from '@/lib/entitlements/multi-user-state'
/** The user's byrå team membership (teams.kind = 'byra'), when any (WL-08). */
export interface ByraTeamRef {
id: string
name: string
role: 'owner' | 'admin' | 'member'
/**
* Where "Tillbaka till klienter" points: '/clients' when the current host
* is the byrå cockpit's home domain, else an absolute URL to that home
* (resolveCockpitHref). Optional: the no-company layout branch renders
* before brands resolve and leaves it unset; consumers fall back to
* '/clients', which on that branch's cockpit/settings surfaces matches the
* pre-cockpitHref behavior exactly.
*/
cockpitHref?: string
}
/**
* A membership company homed on ANOTHER host (the home-domain rule, WL-01):
* shown as a non-clickable "Hanteras via <domain>" signpost entry.
*/
export interface ForeignCompanyEntry {
id: string
name: string
domain: string
}
interface CompanyContextValue {
company: Company | null
role: CompanyRole | null
/**
* Memberships homed on the CURRENT host (home-domain rule): the switcher
* offers only these. Companies homed elsewhere are in foreignCompanies.
*/
companies: { company: Company; role: CompanyRole }[]
isTeamMember: boolean
team: Team | null
/** Byrå team membership; null for everyone outside a byrå (cockpit gate). */
byraTeam?: ByraTeamRef | null
/** Companies homed on another domain, for the "Hanteras via" section. */
foreignCompanies?: ForeignCompanyEntry[]
isSandbox: boolean
/** PAID capability keys the active company currently holds (entitled + enabled). */
capabilities: CapabilityKey[]
/**
* Whether this deployment can run the in-app assistant, i.e. the tool-loop
* runtime behind /api/agent/invoke: getAiStatus().assistantAvailable, false
* without AI credentials or on an OpenAI-compatible endpoint (#2204).
* Deployment-level, not per company. Gates ONLY the surfaces that open
* that runtime; provider-agnostic AI (the single-call ask console,
* categorization, document extraction) never reads it. The route's 503
* stays the real enforcement.
*/
assistantAvailable: boolean
/**
* Trial expiry while the trial is the company's only source of paid access;
* null when paying/comped or after the trial lapsed. Drives the countdown
* touchpoint in the sidebar.
*/
trialEndsAt: string | null
/**
* Paid-lifecycle state (trial / trial_expired / lapsed_subscription /
* paid / none). 'none' in the company:null shells. Drives the expired-trial
* touchpoint and dialog.
*/
entitlementState: EntitlementState
/** When the lapsed trial ran out; set only while entitlementState is 'trial_expired'. */
trialExpiredAt: string | null
/**
* Multi-user seat state of the ACTIVE company (entitled / grace / frozen)
* with the grace deadline. Optional: the no-company shells leave it unset;
* consumers treat absence as entitled (UI affordance only, the server
* enforces).
*/
multiUser?: MultiUserAccess
/**
* Companies in `companies` that are FROZEN for this user (non-owner
* membership, multi_user lapsed past grace): the switcher renders them
* greyed with a lock instead of hiding them. Optional; absent = none.
*/
lockedCompanyIds?: string[]
}
const CompanyContext = createContext<CompanyContextValue | null>(null)
export function CompanyProvider({
children,
value,
}: {
children: React.ReactNode
value: CompanyContextValue
}) {
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>
}
export function useCompany() {
const ctx = useContext(CompanyContext)
if (!ctx) throw new Error('useCompany must be used within CompanyProvider')
return ctx
}
export function useCompanyOptional() {
return useContext(CompanyContext)
}
/**
* Whether the active company holds a given paid capability. Controls UI
* affordances only: the server gate (lib/entitlements) is the real enforcement.
* Fail-open when rendered outside a CompanyProvider (e.g. standalone dialogs):
* the server still blocks; this only decides whether to show/disable/upsell.
*/
export function useCapability(key: CapabilityKey): boolean {
const ctx = useContext(CompanyContext)
if (!ctx) return true
return ctx.capabilities.includes(key)
}
/**
* Whether the deployment can run the in-app assistant (see
* CompanyContextValue.assistantAvailable). Read it at every entry point that
* opens AgentChat / /api/agent/invoke; the single-call console (general.help)
* runs on any provider and does not need it. Fail-open outside a
* CompanyProvider for the same reason as useCapability: the server answers 503.
*/
export function useAssistantAvailable(): boolean {
const ctx = useContext(CompanyContext)
if (!ctx) return true
return ctx.assistantAvailable
}