diff --git a/.specs/mcp-gateway-auth.md b/.specs/mcp-gateway-auth.md index 08dec67d69..0e398a7b1a 100644 --- a/.specs/mcp-gateway-auth.md +++ b/.specs/mcp-gateway-auth.md @@ -61,6 +61,9 @@ when they appear in all capitals. clients. - **Gateway OAuth client**: A dynamically registered OAuth client represented externally as `namespace:name`. +- **Gateway OAuth grant**: A durable, revocable authorization from one Kilo user + to one Gateway OAuth client for one exact scoped connect resource, callback URI, + execution context, connection instance, and scope set. - **Gateway access token**: A short-lived app-issued JWT bound to one user, one scoped connect resource, one execution context, one instance, and one scope set. - **Derived connect token**: A short-lived gateway access token minted by the @@ -280,6 +283,35 @@ when they appear in all capitals. `profile`. 27. `/userinfo` MUST require `profile`; possession of `mcp:access` alone MUST NOT disclose profile claims. +28. Successful interactive approval for a connection that does not require upstream + provider authorization MUST create or reuse an active Gateway OAuth grant. + Approval for a connection that still needs upstream provider authorization MUST + create or reuse a pending Gateway OAuth grant and MUST promote it to active only + after provider callback success. Denial MUST NOT create a grant. +29. Authorization requests, pending provider authorization, authorization codes, + refresh tokens, and OAuth-client access JWTs MUST bind the same Gateway OAuth + grant ID. +30. Reuse is allowed only when client, user, exact callback URI, scoped connect + resource, connection instance, execution context, config version, and granted + scopes are unchanged. A material binding change MUST revoke the old grant and + create a new grant ID; a revoked grant MUST NOT be reactivated. Adding or + removing `refresh_token` client grant capability is a material client metadata + change because it changes whether the client can extend the approved access + duration without another consent prompt. +31. OAuth-client JWTs MUST contain `token_source=oauth_client`, `oauth_grant_id`, + and the external `client_id`. Derived connect tokens MUST contain + `token_source=derived_connect` and MUST NOT contain OAuth client grant identity. +32. Code exchange, refresh, provider callback completion, `/userinfo`, and every + OAuth-client runtime request MUST recheck the bound grant state. Runtime, + `/userinfo`, code exchange, and refresh require an active grant; provider + callback completion may consume only the matching pending grant and must leave a + revoked or missing grant inactive. +33. Revoking a Gateway OAuth grant MUST immediately invalidate pending codes, + refresh tokens, provider authorization attempts, and otherwise valid access + JWTs bound to that grant. It MUST NOT revoke the connection instance, provider + grant, config, route, or another client's Gateway OAuth grant. +34. Users MUST be able to list and revoke only their own Gateway OAuth grants, + including grants for organization-owned connections they were authorized to use. ## Provider Authorization And Grants @@ -340,43 +372,47 @@ when they appear in all capitals. 3. After JWT verification and before fresh runtime-state resolution, credential loading, provider refresh, or upstream access, the Worker MUST require `mcp:access`. -4. The Worker MUST reject stale route keys, disabled/deleted configs, wrong owner +4. Before loading credentials, refreshing provider authorization, or proxying, the + Worker MUST fresh-check the active Gateway OAuth grant for OAuth-client tokens + against the JWT user, client, connection instance, exact connect resource, + execution context, and scopes. Derived connect tokens skip only this grant check. +5. The Worker MUST reject stale route keys, disabled/deleted configs, wrong owner scope, wrong execution context, missing membership, missing assignment, ineligible users, removed instances, missing grants, and version conflicts. -5. The client `Authorization` header is only for gateway authentication and MUST +6. The client `Authorization` header is only for gateway authentication and MUST NOT be forwarded upstream. -6. The Worker MUST use an explicit allowlist for transient client headers and +7. The Worker MUST use an explicit allowlist for transient client headers and strip credential-like headers including `Authorization`, `Proxy-Authorization`, `Cookie`, `X-API-Key`, `X-Auth-*`, `X-Token-*`, and configured static credential names. -7. Static headers and auxiliary headers MUST have valid header names/values and +8. Static headers and auxiliary headers MUST have valid header names/values and MUST NOT be hop-by-hop or credential-confusing. -8. At most one auth source may own upstream `Authorization`. -9. In OAuth modes, the Worker injects the requesting user's bearer provider token. -10. In static-header mode, the Worker injects only the config's static credential +9. At most one auth source may own upstream `Authorization`. +10. In OAuth modes, the Worker injects the requesting user's bearer provider token. +11. In static-header mode, the Worker injects only the config's static credential headers and allowed auxiliary headers. -11. The Worker MUST validate any incoming `Origin` header before credential +12. The Worker MUST validate any incoming `Origin` header before credential injection. Origin-less non-browser clients are allowed; supplied origins MUST match a configured gateway/app origin or be rejected. -12. The Worker MUST stream request and response bodies and MUST NOT buffer unknown +13. The Worker MUST stream request and response bodies and MUST NOT buffer unknown proxy bodies. -13. The Worker MUST reject non-public HTTPS upstream destinations, including +14. The Worker MUST reject non-public HTTPS upstream destinations, including loopback, private, link-local, reserved, and non-public IPv4/IPv6 results. -14. DNS validation MUST consider both A and AAAA answers and fail closed when the +15. DNS validation MUST consider both A and AAAA answers and fail closed when the destination cannot be safely validated. Because Workers cannot pin arbitrary third-party DNS answers across zones, this is a best-effort defense rather than a complete DNS-rebinding guarantee for untrusted external origins. -15. The Worker MUST NOT follow upstream redirects in v1. It may return 3xx +16. The Worker MUST NOT follow upstream redirects in v1. It may return 3xx responses to clients, but it must not forward injected credentials to a redirect target. -16. The Worker MUST NOT expose a provider token-exchange API. +17. The Worker MUST NOT expose a provider token-exchange API. ## Audit, Privacy, And Cleanup 1. The system MUST record sanitized audit events for config creation/update/ disable/delete, route rotation/revocation, assignment change, authorization - outcome, provider authorization outcome, provider grant change, refresh - outcome, and runtime usage. + outcome, Gateway OAuth grant creation/revocation, provider authorization outcome, + provider grant change, refresh outcome, and runtime usage. 2. Audit events MUST include actor when available, owner scope, owner ID, config ID, route/instance IDs when applicable, event type, outcome, timestamp, and non-secret correlation metadata. @@ -385,12 +421,14 @@ when they appear in all capitals. header secrets, gateway refresh tokens, authorization codes, PKCE verifiers, auth headers, cookies, or raw provider payloads. 4. Soft-delete or anonymization of a user MUST remove or anonymize user-associated - instances, provider grants, pending provider state, and other sensitive - gateway material while retaining only non-sensitive audit history where - required. -5. Org removal MUST revoke/remove the user's org instances, grants, assignments, - and pending provider state immediately. -6. Audit rows older than 60 days MAY be removed by the Worker cleanup job. + instances, Gateway OAuth grants, provider grants, pending provider state, and + other sensitive gateway material while retaining only non-sensitive audit + history where required. +5. Org removal MUST revoke/remove the user's org instances, Gateway OAuth grants, + provider grants, assignments, and pending provider state immediately. +6. Active Gateway OAuth grants MUST NOT be removed by age. Revoked grants MAY be + deleted under an explicit retention policy. +7. Audit rows older than 60 days MAY be removed by the Worker cleanup job. ## Error Handling diff --git a/CONTEXT.md b/CONTEXT.md index d3ecee3387..80f8c8a00e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,12 +2,13 @@ ## Scope -Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contract currently defines Security Agent language and notification ownership boundaries used across sync, web, email, remediation, tests, and product documentation. +Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contract defines Code Reviewer and Security Agent language plus ownership boundaries used across review execution, analytics, sync, web, email, remediation, tests, and product documentation. ## Contexts | Context | Owns | Location | Notes | |---|---|---|---| +| **Code Reviewer** | Pull request and merge request review execution, Code Review Findings, review settings, and Review Analytics | `apps/web/src/lib/code-reviews/`, `apps/web/src/components/code-reviews/` | A Code Reviewer owner is either one user or one organization; Review Analytics collection is organization- and platform-scoped | | **Security Agent** | Security Findings, owner-scoped policy, settings, Auto Remediation, and user-visible outcomes | `apps/web/src/lib/security-agent/`, `apps/web/src/components/security-agent/`, `.specs/security-agent.md` | A Security Agent owner is either one user or one organization | | **Security Sync** | Dependabot synchronization, finding persistence, notification eligibility, recipient intent materialization, and durable notification state | `services/security-sync/` | Event state remains owner-scoped; email sending does not occur inside finding persistence transactions | | **Security Agent Email Delivery** | Dispatch-time revalidation, email rendering, owner-aware links, and Mailgun delivery | `apps/web/src/app/api/internal/security-agent/`, `apps/web/src/lib/email.ts`, `apps/web/src/emails/` | Accepts notification identity only and loads current data before sending | @@ -17,6 +18,10 @@ Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contr | Term | Agent meaning | Use this when | Avoid | |---|---|---|---| +| **Code Reviewer** | Agent that reviews pull requests and merge requests and may raise Code Review Findings | Naming the product capability, settings, review execution, and analytics | Security Agent, review bot | +| **Code Review Finding** | Model-generated issue newly raised by Code Reviewer during one review execution | Referring to Code Reviewer output or its controlled analytics taxonomy | Security Finding, confirmed bug, verified vulnerability | +| **Review Analytics** | Organization-only, opt-in prospective collection of bounded classifications for completed reviews and newly raised Code Review Findings | Referring to the Code Reviewer Analytics tab, collection setting, coverage, or aggregate metrics | Security Agent analytics, historical backfill | +| **AI-estimated impact** | Code Reviewer's low, medium, or high estimate of a change's reach and consequence, independent of diff size, change type, complexity, and finding count | Referring to impact classifications or derived impact points | Developer quality, individual performance, delivered impact | | **Security Agent** | Agent that syncs, analyzes, and helps resolve repository Security Findings | Naming product capability, settings, routes, and behavior | Security Reviews | | **Security Finding** | Vulnerability item owned by one user or organization for a repository, usually synced from Dependabot | Referring to Kilo's persisted vulnerability domain object | Security review, alert | | **Auto Remediation** | Security Agent feature that automatically starts Security Remediations for eligible Security Findings | Referring to policy-driven remediation admission | Auto Fix | @@ -32,6 +37,9 @@ Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contr ## Relationships +- A **Code Review Finding** belongs to one captured Code Reviewer review result and contains only controlled taxonomy values in Review Analytics. +- **Review Analytics** enrollment is available only to organization-owned reviews and is snapshotted when a Code Reviewer execution attempt is dispatched; changing the setting does not change an in-flight attempt. +- **AI-estimated impact** describes a reviewed change and remains independent from Code Review Finding counts. - A **Security Finding** belongs to exactly one Security Agent owner: one user or one organization. - A **Security Finding** can create at most one **Security Agent Notification** of each kind per **Notification Recipient**. - A **New-finding Notification** depends on first insertion into Kilo, not source alert creation time. @@ -42,6 +50,10 @@ Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contr ## Agent Rules +- Use **Code Review Finding** for an issue raised by Code Reviewer. Never call it a **Security Finding**, even when its category is `security`. +- Describe Review Analytics values as model-generated signals: use "findings raised" and **AI-estimated impact**, not confirmed bugs, verified vulnerabilities, or developer quality. +- Keep Review Analytics organization-only, prospective, and opt-in. Missing, invalid, or omitted structured results are unavailable coverage states, not zero-finding reviews. +- Do not persist finding prose, code, paths, lines, symbols, prompts, raw manifests, or full assistant output in Review Analytics. - Use **Security Finding** for Kilo's persisted domain object. Use "Dependabot alert" only for external source object at GitHub boundary. - Use exact notification kind when discussing eligibility or history: **New-finding Notification**, **SLA Warning Notification**, or **SLA Breach Notification**. - Treat "new" as first insertion for owner in Kilo. Updates and reopening do not make finding new again. @@ -55,6 +67,8 @@ Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contr | Ambiguous term | Problem | Canonical decision | |---|---|---| +| finding | Can mean Code Reviewer output or the Security Agent's persisted vulnerability object | Use **Code Review Finding** for Code Reviewer output and **Security Finding** only for the Security Agent domain object | +| impact | Can imply delivered business value, diff size, complexity, or individual performance | Use **AI-estimated impact** only for the model-generated reach-and-consequence classification | | alert | Can mean external Dependabot alert, persisted Security Finding, or outgoing notification | Use "Dependabot alert" at source boundary, **Security Finding** after persistence, and exact notification kind for outgoing event | | notification email | Conflates durable semantic event with retryable provider side effect | Use **Security Agent Notification** for event and **Email Delivery** for send attempt | | new finding | Can mean newly created at source, first observed, inserted, updated, or reopened | For notification policy, it means first insertion into Kilo for owner | @@ -63,6 +77,8 @@ Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contr ## Context Boundaries +- **Code Reviewer** owns review execution, Code Review Findings, Review Analytics settings, and user-visible aggregate review signals. +- Review Analytics stores bounded taxonomy observations separately from Security Agent `security_findings` and does not establish a cross-review finding lifecycle. - **Security Agent** owns product policy, settings, permissions, and user-visible finding/remediation outcomes. - **Security Sync** owns finding synchronization, notification event admission, recipient intent materialization, deduplication, and durable state transitions. - **Security Agent Email Delivery** may revalidate and deliver an existing notification but must not create notification eligibility or copy mutable finding data into Worker request. @@ -71,5 +87,6 @@ Kilo Code Cloud hosts Kilo Code agents, integrations, and automation. This contr ## Decision References +- `.plans/code-review-analytics.md` defines prospective Review Analytics collection, taxonomy, persistence, and metric semantics. - `.specs/security-agent.md` defines Security Agent Auto Remediation and notification guarantees. - `.plans/security-agent-notifications.md` records notification implementation and rollout design. diff --git a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx index a93500fe3f..3ac89e5ff1 100644 --- a/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/model-picker.tsx @@ -7,9 +7,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text } from '@/components/ui/text'; import { + BYOK_MODEL_LABEL, FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, - getFreeModelDataAccessibilityLabel, + hasUserByokAvailable, isFreeModelOption, mayTrainOnYourPrompts, } from '@/lib/free-model-data-disclosure'; @@ -203,8 +204,18 @@ export default function ModelPickerScreen() { const modelOption = item.model; const selected = modelOption.id === selectedModel; const free = isFreeModelOption(modelOption); + const byok = hasUserByokAvailable(modelOption); const collectsData = mayTrainOnYourPrompts(modelOption); const hasVariants = modelOption.variants.length > 1; + const accessibilityLabel = [ + modelOption.name, + byok ? BYOK_MODEL_LABEL : undefined, + free && !byok ? FREE_MODEL_FREE_LABEL : undefined, + collectsData ? FREE_MODEL_DATA_LABEL : undefined, + selected ? 'selected' : undefined, + ] + .filter(Boolean) + .join(', '); return ( @@ -214,14 +225,14 @@ export default function ModelPickerScreen() { handleSelectModel(modelOption.id); }} accessibilityRole="button" - accessibilityLabel={`${collectsData ? getFreeModelDataAccessibilityLabel(modelOption.name) : modelOption.name}${selected ? ', selected' : ''}`} + accessibilityLabel={accessibilityLabel} > {modelOption.name} {modelOption.id} - {free || collectsData ? ( + {free || byok || collectsData ? ( - {free ? ( + {free && !byok ? ( ) : null} + {byok ? ( + + + {BYOK_MODEL_LABEL} + + + ) : null} {collectsData ? ( m.id === value); const label = selectedModel?.name ?? (value || 'Model'); + const byok = hasUserByokAvailable(selectedModel); const collectsData = mayTrainOnYourPrompts(selectedModel); const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false; const variantLabel = variant ? thinkingEffortLabel(variant) : ''; const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : ''; const dataLabel = collectsData ? getFreeModelDataAccessibilityLabel(label) : label; + const modelLabel = byok ? `${dataLabel}, ${BYOK_MODEL_LABEL}` : dataLabel; const accessibilityLabel = - hasVariants && variantLabel ? `${dataLabel}, ${variantLabel} thinking effort` : dataLabel; + hasVariants && variantLabel ? `${modelLabel}, ${variantLabel} thinking effort` : modelLabel; function handlePress() { if (effectivelyDisabled) { @@ -82,6 +86,13 @@ export function ModelSelector({ > {label} + {byok ? ( + + + {BYOK_MODEL_LABEL} + + + ) : null} {collectsData ? : null} {hasVariants && compactVariantLabel ? ( diff --git a/apps/mobile/src/lib/free-model-data-disclosure.test.ts b/apps/mobile/src/lib/free-model-data-disclosure.test.ts index 2e8563c6ea..0fe5ec37ec 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.test.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it } from 'vitest'; import { + BYOK_MODEL_LABEL, FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, + hasUserByokAvailable, isFreeModelOption, mayTrainOnYourPrompts, } from './free-model-data-disclosure'; describe('free model data disclosure', () => { it('uses the disclosure label expected in model pickers', () => { + expect(BYOK_MODEL_LABEL).toBe('BYOK'); expect(FREE_MODEL_DATA_LABEL).toBe('Data collected'); expect(FREE_MODEL_FREE_LABEL).toBe('Free'); }); @@ -39,6 +42,22 @@ describe('free model data disclosure', () => { expect(mayTrainOnYourPrompts({ id: 'free-model', isFree: true })).toBe(false); }); + it('detects only explicit user BYOK availability', () => { + expect( + hasUserByokAvailable({ + id: 'anthropic/claude', + hasUserByokAvailable: true, + }) + ).toBe(true); + expect( + hasUserByokAvailable({ + id: 'anthropic/claude', + hasUserByokAvailable: false, + }) + ).toBe(false); + expect(hasUserByokAvailable({ id: 'anthropic/claude' })).toBe(false); + }); + it('adds a data collection phrase to accessibility labels', () => { expect(getFreeModelDataAccessibilityLabel('Kilo Auto')).toBe('Kilo Auto, Data collected'); }); diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index 0f214c7f2b..850e66dfb7 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -1,3 +1,4 @@ +export const BYOK_MODEL_LABEL = 'BYOK'; export const FREE_MODEL_DATA_LABEL = 'Data collected'; export const FREE_MODEL_FREE_LABEL = 'Free'; @@ -5,6 +6,7 @@ type ModelDataDisclosure = { id: string; isFree?: boolean; mayTrainOnYourPrompts?: boolean; + hasUserByokAvailable?: boolean; }; export function isFreeModelOption(model: ModelDataDisclosure | undefined) { @@ -15,6 +17,10 @@ export function mayTrainOnYourPrompts(model: ModelDataDisclosure | undefined) { return model?.mayTrainOnYourPrompts === true; } +export function hasUserByokAvailable(model: ModelDataDisclosure | undefined) { + return model?.hasUserByokAvailable === true; +} + export function getFreeModelDataAccessibilityLabel(label: string) { return `${label}, ${FREE_MODEL_DATA_LABEL}`; } diff --git a/apps/mobile/src/lib/hooks/use-available-models.ts b/apps/mobile/src/lib/hooks/use-available-models.ts index 19646c9535..2bbc2901fc 100644 --- a/apps/mobile/src/lib/hooks/use-available-models.ts +++ b/apps/mobile/src/lib/hooks/use-available-models.ts @@ -14,6 +14,7 @@ export type ModelOption = { isPreferred: boolean; isFree?: boolean; mayTrainOnYourPrompts?: boolean; + hasUserByokAvailable?: boolean; }; type ModelResponse = { @@ -22,6 +23,7 @@ type ModelResponse = { name: string; isFree?: boolean; mayTrainOnYourPrompts?: boolean; + hasUserByokAvailable?: boolean; preferredIndex?: number; opencode?: { variants?: Record; @@ -104,6 +106,7 @@ export function useAvailableModels(organizationId: string | undefined) { name: formatShortModelName(model.name), isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, variants: Object.keys(model.opencode?.variants ?? {}), preferredIndex: model.preferredIndex, })); @@ -131,6 +134,7 @@ export function useAvailableModels(organizationId: string | undefined) { isPreferred: item.preferredIndex !== undefined, isFree: item.isFree, mayTrainOnYourPrompts: item.mayTrainOnYourPrompts, + hasUserByokAvailable: item.hasUserByokAvailable, })); }, [data]); diff --git a/apps/storybook/stories/code-reviews/AnalyticsBreakdownBars.stories.tsx b/apps/storybook/stories/code-reviews/AnalyticsBreakdownBars.stories.tsx new file mode 100644 index 0000000000..f8a6ff8b04 --- /dev/null +++ b/apps/storybook/stories/code-reviews/AnalyticsBreakdownBars.stories.tsx @@ -0,0 +1,122 @@ +import type { ComponentProps } from 'react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { AnalyticsBreakdownBars } from '@/components/code-reviews/analytics/AnalyticsBreakdownBars'; + +type AnalyticsBreakdownBarsArgs = ComponentProps; + +const populatedArgs = { + impactBreakdown: { + impact: { + low: 5, + medium: 11, + high: 6, + unclassified: 2, + }, + complexity: [ + { value: 'high', count: 6, lowConfidenceCount: 1 }, + { value: 'low', count: 8, lowConfidenceCount: 0 }, + { value: 'medium', count: 10, lowConfidenceCount: 1 }, + ], + changeTypes: [ + { value: 'feature', count: 5, lowConfidenceCount: 1 }, + { value: 'documentation', count: 1, lowConfidenceCount: 0 }, + { value: 'bug_fix', count: 5, lowConfidenceCount: 0 }, + { value: 'other', count: 1, lowConfidenceCount: 0 }, + { value: 'refactor', count: 4, lowConfidenceCount: 0 }, + { value: 'test', count: 2, lowConfidenceCount: 0 }, + { value: 'maintenance', count: 3, lowConfidenceCount: 1 }, + { value: 'mixed', count: 1, lowConfidenceCount: 0 }, + { value: 'dependency', count: 2, lowConfidenceCount: 0 }, + ], + }, + modelBreakdown: [ + { + model: 'anthropic/claude-sonnet-4.6', + trackedReviews: 14, + totalFindings: 18, + criticalFindings: 4, + warningFindings: 10, + suggestionFindings: 4, + }, + { + model: 'openai/gpt-5.1', + trackedReviews: 10, + totalFindings: 13, + criticalFindings: 1, + warningFindings: 7, + suggestionFindings: 5, + }, + ], + findingBreakdown: [ + { value: 'correctness', total: 10, critical: 1, warning: 6, suggestion: 3 }, + { value: 'security', total: 6, critical: 2, warning: 3, suggestion: 1 }, + { value: 'reliability', total: 5, critical: 0, warning: 4, suggestion: 1 }, + { value: 'maintainability', total: 4, critical: 0, warning: 1, suggestion: 3 }, + { value: 'performance', total: 3, critical: 0, warning: 2, suggestion: 1 }, + { value: 'test_quality', total: 2, critical: 0, warning: 0, suggestion: 2 }, + { value: 'data_integrity', total: 1, critical: 1, warning: 0, suggestion: 0 }, + ], + securityBreakdown: [ + { value: 'auth_access', total: 2, critical: 1, warning: 1, suggestion: 0 }, + { value: 'injection', total: 2, critical: 1, warning: 1, suggestion: 0 }, + { + value: 'dependency_supply_chain', + total: 1, + critical: 0, + warning: 0, + suggestion: 1, + }, + { + value: 'request_resource_boundary', + total: 1, + critical: 0, + warning: 1, + suggestion: 0, + }, + ], +} satisfies AnalyticsBreakdownBarsArgs; + +const emptyOptionalDataArgs = { + impactBreakdown: { + impact: { + low: 0, + medium: 0, + high: 0, + unclassified: 0, + }, + complexity: [], + changeTypes: [], + }, + modelBreakdown: [], + findingBreakdown: [], + securityBreakdown: [], +} satisfies AnalyticsBreakdownBarsArgs; + +const meta = { + title: 'Code Reviews/Analytics/AnalyticsBreakdownBars', + component: AnalyticsBreakdownBars, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + Story => ( +
+
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Populated: Story = { + args: populatedArgs, +}; + +export const EmptyOptionalData: Story = { + args: emptyOptionalDataArgs, +}; diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index f6164f9927..76f2b1d83a 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -2135,6 +2135,7 @@ export function SettingsTab({ name: model.name, isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, })), trackedOpenClawVersion: trackedVersion, runningOpenClawVersion: runningVersion, @@ -2367,7 +2368,17 @@ export function SettingsTab({
diff --git a/apps/web/src/app/(app)/claw/components/VersionPinCard.tsx b/apps/web/src/app/(app)/claw/components/VersionPinCard.tsx index 8b32ed7908..1ff51b4cf7 100644 --- a/apps/web/src/app/(app)/claw/components/VersionPinCard.tsx +++ b/apps/web/src/app/(app)/claw/components/VersionPinCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import React, { useState } from 'react'; import { Pin, PinOff, Info } from 'lucide-react'; import { toast } from 'sonner'; import { toastPinMutationResult } from '@/lib/kiloclaw/pin-sync-toast'; @@ -16,16 +16,133 @@ import { } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; type ClawMutations = ReturnType; +function truncateTag(tag: string) { + if (tag.length <= 20) return tag; + return `${tag.slice(0, 8)}...${tag.slice(-8)}`; +} + +/** + * One row in the compact image table: a label plus the OpenClaw version and + * the (truncated) image tag/hash. Either piece may be missing — a row renders + * whatever it has, falling back to "Unknown" only when both are absent. + */ +function ImageRow({ + label, + openclawVersion, + imageTag, + tooltip, + spaced = false, +}: { + label: string; + openclawVersion: string | null | undefined; + imageTag: string | null | undefined; + /** Optional explanatory text surfaced via an info icon next to the label. */ + tooltip?: string; + spaced?: boolean; +}) { + return ( + + + + {label} + {tooltip && ( + + + + + {tooltip} + + )} + + + + + {openclawVersion && OpenClaw {openclawVersion}} + {imageTag ? ( + + {truncateTag(imageTag)} + + ) : ( + !openclawVersion && Unknown + )} + + + + ); +} + +/** + * The "Active" and "Latest" image rows shown to an unpinned instance. Each + * pairs the OpenClaw version with its image tag/hash so the two columns read + * e.g. "Active OpenClaw 2026.6.5 img-5f02b9408089". Rendered inside a + * ; a row is omitted only when both its version and tag are absent. + */ +export function VersionImageMetadata({ + currentOpenClawVersion, + trackedImageTag, + latestOpenClawVersion, + latestImageTag, +}: { + currentOpenClawVersion: string | null | undefined; + trackedImageTag: string | null | undefined; + latestOpenClawVersion: string | null | undefined; + latestImageTag: string | null | undefined; +}) { + // The active and latest images can share the same OpenClaw version while + // still being different builds (the image tag differs). In that case the + // "Update available" framing would be confusing, so explain that the latest + // image carries non-version changes rather than a newer OpenClaw release. + const sameOpenClawVersionDifferentImage = + !!currentOpenClawVersion && + !!latestOpenClawVersion && + currentOpenClawVersion === latestOpenClawVersion && + !!trackedImageTag && + !!latestImageTag && + trackedImageTag !== latestImageTag; + + return ( + <> + {(trackedImageTag || currentOpenClawVersion) && ( + + )} + {(latestImageTag || latestOpenClawVersion) && ( + + )} + + ); +} + export function VersionPinCard({ trackedImageTag, + trackedOpenClawVersion, latestImageTag, + latestOpenClawVersion, mutations, }: { trackedImageTag: string | null; + trackedOpenClawVersion: string | null; latestImageTag: string | null; + latestOpenClawVersion: string | null; mutations: ClawMutations; }) { const { data: myPin, isLoading: pinLoading } = useClawMyPin(); @@ -90,11 +207,6 @@ export function VersionPinCard({ ); } - const truncateTag = (tag: string) => { - if (tag.length <= 20) return tag; - return `${tag.slice(0, 8)}...${tag.slice(-8)}`; - }; - return (

@@ -219,46 +331,18 @@ export function VersionPinCard({ {isPinned ? ( - - - - + ) : ( - <> - {trackedImageTag && ( - - - - - )} - {latestImageTag && ( - - - - - )} - + )}
Pinned image - - {truncateTag(myPin.image_tag)} - -
Current image - - {truncateTag(trackedImageTag)} - -
Latest image - - {truncateTag(latestImageTag)} - -
diff --git a/apps/web/src/app/(app)/claw/components/version-display.test.ts b/apps/web/src/app/(app)/claw/components/version-display.test.ts new file mode 100644 index 0000000000..ae2ebc9af9 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/version-display.test.ts @@ -0,0 +1,84 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { VersionImageMetadata } from './VersionPinCard'; + +// The same-version explanation is surfaced as the info trigger's accessible +// name (aria-label), which renders into static markup. We deliberately assert +// against the aria-label rather than the Radix TooltipContent: that content +// lives in a portal and only mounts when the tooltip is open, so it never +// appears in renderToStaticMarkup output. +const SAME_VERSION_EXPLANATION = + 'Both images run the same OpenClaw version, but the latest image includes additional fixes, improvements, and features.'; +const SAME_VERSION_ARIA_LABEL = `aria-label="${SAME_VERSION_EXPLANATION}"`; + +describe('KiloClaw version display', () => { + it('pairs current and latest OpenClaw versions with their image tags', () => { + const html = renderToStaticMarkup( + createElement( + 'table', + null, + createElement( + 'tbody', + null, + createElement(VersionImageMetadata, { + currentOpenClawVersion: '2026.6.5', + trackedImageTag: 'img-5f02b9408089', + latestOpenClawVersion: '2026.6.8', + latestImageTag: 'img-048842db6829', + }) + ) + ) + ); + + expect(html).toContain('Active'); + expect(html).toContain('OpenClaw 2026.6.5'); + expect(html).toContain('img-5f02b9408089'); + expect(html).toContain('Latest'); + expect(html).toContain('OpenClaw 2026.6.8'); + expect(html).toContain('img-048842db6829'); + // Different OpenClaw versions: no "same version" explanation trigger. + expect(html).not.toContain(SAME_VERSION_ARIA_LABEL); + }); + + it('explains when active and latest share an OpenClaw version but differ by image', () => { + const html = renderToStaticMarkup( + createElement( + 'table', + null, + createElement( + 'tbody', + null, + createElement(VersionImageMetadata, { + currentOpenClawVersion: '2026.6.8', + trackedImageTag: 'img-5f02b9408089', + latestOpenClawVersion: '2026.6.8', + latestImageTag: 'img-048842db6829', + }) + ) + ) + ); + + expect(html).toContain(SAME_VERSION_ARIA_LABEL); + }); + + it('omits the explanation when active and latest are the same image', () => { + const html = renderToStaticMarkup( + createElement( + 'table', + null, + createElement( + 'tbody', + null, + createElement(VersionImageMetadata, { + currentOpenClawVersion: '2026.6.8', + trackedImageTag: 'img-048842db6829', + latestOpenClawVersion: '2026.6.8', + latestImageTag: 'img-048842db6829', + }) + ) + ) + ); + + expect(html).not.toContain(SAME_VERSION_ARIA_LABEL); + }); +}); diff --git a/apps/web/src/app/(app)/claw/hooks/useClawModelOptions.ts b/apps/web/src/app/(app)/claw/hooks/useClawModelOptions.ts index 2d0cb6208f..bad2ddf87e 100644 --- a/apps/web/src/app/(app)/claw/hooks/useClawModelOptions.ts +++ b/apps/web/src/app/(app)/claw/hooks/useClawModelOptions.ts @@ -57,6 +57,7 @@ export function useClawModelOptions(enabled = true): { name: model.name, isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, })), trackedOpenClawVersion: trackedVersion, runningOpenClawVersion: runningVersion, diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/AuthorizedClientsContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/AuthorizedClientsContent.tsx new file mode 100644 index 0000000000..a95fa816e9 --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/AuthorizedClientsContent.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; +import { ShieldCheck, ShieldX, ChevronDown } from 'lucide-react'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; + +function permissionLabel(scope: string) { + if (scope === 'mcp:access') return 'Read, write, and act through this MCP connection'; + if (scope === 'profile') return 'View your Kilo profile'; + return scope; +} + +function isBroadAccessScope(scope: string) { + return scope === 'mcp:access'; +} + +type AuthorizedClientsContentProps = { + organizationId?: string; +}; + +export function AuthorizedClientsContent({ organizationId }: AuthorizedClientsContentProps = {}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [revokeGrantId, setRevokeGrantId] = useState(null); + const queryInput = organizationId ? { organizationId } : ({ ownerScope: 'personal' } as const); + const listQuery = useQuery(trpc.mcpGatewayAuthorizations.listMine.queryOptions(queryInput)); + const revokeTarget = (listQuery.data ?? []).find(grant => grant.grantId === revokeGrantId); + const revokeMutation = useMutation( + trpc.mcpGatewayAuthorizations.revoke.mutationOptions({ + onSuccess: () => { + toast.success('Client access revoked'); + if (revokeGrantId) { + queryClient.setQueryData( + trpc.mcpGatewayAuthorizations.listMine.queryKey(queryInput), + (current: typeof listQuery.data) => + current?.filter(grant => grant.grantId !== revokeGrantId) ?? current + ); + } + setRevokeGrantId(null); + void queryClient.invalidateQueries({ + queryKey: trpc.mcpGatewayAuthorizations.listMine.queryKey(queryInput), + }); + }, + onError: error => toast.error(error.message || "We couldn't revoke client access"), + }) + ); + + return ( +
+ {listQuery.isLoading && !listQuery.data && ( +
+ + + +
+ )} + {listQuery.isError && !listQuery.data && ( +
+

We couldn't load authorized clients. Try again.

+ +
+ )} + {listQuery.isError && listQuery.data && ( +
+

+ Showing the last loaded list. We couldn't refresh authorized clients. +

+ +
+ )} + {listQuery.data?.length === 0 && ( +
+
+ )} + {listQuery.data?.map(grant => { + const connectionHref = getMcpGatewayRoutes( + grant.context.type === 'organization' ? grant.context.organizationId : undefined + ).detail(grant.configId); + const onBehalfOf = + grant.context.type === 'organization' ? grant.context.organizationName : 'yourself'; + const hasBroadAccess = grant.scopes.some(isBroadAccessScope); + const otherScopes = grant.scopes.filter(scope => !isBroadAccessScope(scope)); + return ( + + +
+
+ + + + + + Unverified — this name was provided by the client and has not been confirmed. + + +

+ {grant.clientName ? `“${grant.clientName}”` : 'Unverified MCP client'} +

+
+

+ Authorized to use{' '} + + {grant.connectionName} + {' '} + on behalf of {onBehalfOf} +

+
+ +
+ + {hasBroadAccess && ( +
+

+ What this grants +

+

+ Read, write, and act through{' '} + {grant.connectionName} on your behalf, + using whatever tools that connection exposes. +

+ {otherScopes.length > 0 && ( +
+ {otherScopes.map(scope => ( + + {permissionLabel(scope)} + + ))} +
+ )} +
+ )} +
+ + Authorized {formatDistanceToNow(new Date(grant.approvedAt), { addSuffix: true })} + + + + {grant.lastUsedAt + ? `Last used ${formatDistanceToNow(new Date(grant.lastUsedAt), { addSuffix: true })}` + : 'Not used yet'} + +
+
+ + +
+
+
Client ID
+
+ {grant.clientId} +
+
+
+
Callback URI
+
+ {grant.redirectUri} +
+
+
+
+
+
+ ); + })} + { + if (!open) setRevokeGrantId(null); + }} + > + + + Revoke access for this client? + + {revokeTarget ? ( +
+

+ This unverified client will immediately lose access to{' '} + {revokeTarget.connectionName}. It must be + authorized again before it can use this MCP connection. +

+
+
+
Client ID
+
+ {revokeTarget.clientId} +
+
+
+
Callback URI
+
+ {revokeTarget.redirectUri} +
+
+
+
+ ) : ( +

+ This unverified client will immediately lose access to this MCP connection. It + must be authorized again before it can use the connection. +

+ )} +
+
+ + Keep access + { + if (!revokeTarget) return; + revokeMutation.mutate({ grantId: revokeTarget.grantId }); + }} + > + {revokeMutation.isPending ? 'Revoking...' : 'Revoke access'} + + +
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx index 3115d0dd40..0a51abd413 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx @@ -29,9 +29,11 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Copy, Plus, Settings, Trash2 } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; +import { AuthorizedClientsContent } from './AuthorizedClientsContent'; type McpGatewayListContentProps = { organizationId?: string; @@ -96,180 +98,195 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP return (
-
-
-

MCP Gateway

-

- Create and manage remote MCP server connections for Kilo Code. -

-
- +
+

MCP Gateway

+

+ Create and manage remote MCP server connections for Kilo Code. +

- - -
-
- Connections - {!listQuery.isLoading && !listQuery.isError && ( - - {filteredConnections.length} - - )} -
- Remote MCP servers available to Kilo Code. -
- setFilter(event.target.value)} - placeholder="Filter connections" - aria-label="Filter connections" - className="w-full sm:w-64" - /> -
- - {listQuery.isLoading && ( -
- - - -
- )} - {listQuery.isError && ( -
-

We couldn't load connections. Try again.

- -
- )} - {!listQuery.isLoading && !listQuery.isError && filteredConnections.length === 0 && ( -
-

- {listQuery.data?.length - ? 'No connections match that filter.' - : 'No MCP connections yet.'} -

-

- {listQuery.data?.length - ? 'Clear the filter to see every connection.' - : 'Create one to connect Kilo Code to a remote MCP server.'} -

- {listQuery.data?.length ? ( - - ) : ( - +
+ + + {listQuery.isLoading && ( +
+ + + +
)} -
- )} - {!listQuery.isLoading && !listQuery.isError && filteredConnections.length > 0 && ( -
- - - - Name - Status - {organizationId && Assigned users} - Last updated - Actions - - - - {filteredConnections.map(connection => ( - - -
- - {connection.name} - - - {remoteHost(connection.remoteUrl)} - -
-
- - - - {organizationId && ( - {connection.assignmentCount} - )} - - - {formatDistanceToNow(new Date(connection.updatedAt), { - addSuffix: true, - })} - - - -
- - - - - Manage connection - - - - - - Copy connect URL - - - - +
+ )} + {!listQuery.isLoading && !listQuery.isError && filteredConnections.length === 0 && ( +
+

+ {listQuery.data?.length + ? 'No connections match that filter.' + : 'No MCP connections yet.'} +

+

+ {listQuery.data?.length + ? 'Clear the filter to see every connection.' + : 'Create one to connect Kilo Code to a remote MCP server.'} +

+ {listQuery.data?.length ? ( + + ) : ( + + )} +
+ )} + {!listQuery.isLoading && !listQuery.isError && filteredConnections.length > 0 && ( +
+
+ + + Name + Status + {organizationId && Assigned users} + Last updated + Actions + + + + {filteredConnections.map(connection => ( + + +
+ - - - - Delete connection - -
-
-
- ))} -
-
-
- )} - - + {connection.name} + + + {remoteHost(connection.remoteUrl)} + +
+ + + + + {organizationId && ( + + {connection.assignmentCount} + + )} + + + {formatDistanceToNow(new Date(connection.updatedAt), { + addSuffix: true, + })} + + + +
+ + + + + Manage connection + + + + + + Copy connect URL + + + + + + Delete connection + +
+
+ + ))} + + +

+ )} + + + + + + + { diff --git a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx index 2184536371..edd73037f6 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/[triggerId]/EditWebhookTriggerContent.tsx @@ -92,6 +92,7 @@ export function EditWebhookTriggerContent({ name: model.name, isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, })), [modelsData?.data] ); diff --git a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx index 2723ab0a64..e80b24139f 100644 --- a/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx +++ b/apps/web/src/app/(app)/cloud/webhooks/new/CreateWebhookTriggerContent.tsx @@ -78,6 +78,7 @@ export function CreateWebhookTriggerContent({ organizationId }: CreateWebhookTri name: model.name, isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, })), [modelsData?.data] ); diff --git a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx index ad5934cc4c..432aaba5ed 100644 --- a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx +++ b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx @@ -11,7 +11,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { SetPageTitle } from '@/components/SetPageTitle'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Rocket, ExternalLink, Settings2, ListChecks, Brain } from 'lucide-react'; +import { Brain, ExternalLink, ListChecks, Rocket, Settings2 } from 'lucide-react'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; diff --git a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx index 62be6bb98a..3c9f584121 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx @@ -102,6 +102,7 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) name: model.name, isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, })) ?? [], [modelsData] ); diff --git a/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx index 3cc0b05d8d..a91442cc3e 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx @@ -147,6 +147,7 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI name: model.name, isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, })) ?? [], [modelsData] ); diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx index 28243e8e70..967b11b722 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx +++ b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx @@ -335,6 +335,7 @@ export function OnboardingStepModel() { name: model.name, isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, + hasUserByokAvailable: model.hasUserByokAvailable, })) ?? [], [modelsData] ); diff --git a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx index 96e1fbb33a..3acabb7a23 100644 --- a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx @@ -6,12 +6,20 @@ import { ReviewConfigForm } from '@/components/code-reviews/ReviewConfigForm'; import { CodeReviewActionRequiredAlert } from '@/components/code-reviews/CodeReviewActionRequiredAlert'; import { CodeReviewJobsCard } from '@/components/code-reviews/CodeReviewJobsCard'; import { ReviewMemoryPanel } from '@/components/code-reviews/ReviewMemoryPanel'; +import { CodeReviewAnalyticsPanel } from '@/components/code-reviews/analytics/CodeReviewAnalyticsPanel'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { SetPageTitle } from '@/components/SetPageTitle'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Rocket, ExternalLink, Settings2, ListChecks, Brain } from 'lucide-react'; +import { + Brain, + ChartColumnIncreasing, + ExternalLink, + ListChecks, + Rocket, + Settings2, +} from 'lucide-react'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; @@ -177,7 +185,7 @@ export function ReviewAgentPageClient({ {/* GitHub Configuration Tabs */} - + Config @@ -198,6 +206,10 @@ export function ReviewAgentPageClient({ Memory + + + Analytics + @@ -232,6 +244,10 @@ export function ReviewAgentPageClient({ )} + + + + @@ -266,7 +282,7 @@ export function ReviewAgentPageClient({ {/* GitLab Configuration Tabs */} - + Config @@ -279,6 +295,10 @@ export function ReviewAgentPageClient({ Jobs + + + Analytics + @@ -315,6 +335,10 @@ export function ReviewAgentPageClient({ )} + + + + diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx index b54b93bf93..869a0a4265 100644 --- a/apps/web/src/app/(app)/profile/page.tsx +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -27,6 +27,7 @@ import { isFeatureFlagEnabled } from '@/lib/posthog-feature-flags'; import { UserProfileCard } from '@/components/profile/UserProfileCard'; import { ProfileKiloClawBanner } from '@/components/profile/ProfileKiloClawBanner'; import { getContributorChampionProfileBadgeForUser } from '@/lib/contributor-champions/service'; +import { AutoRoutingModeCard } from '@/components/auto-routing/AutoRoutingModeCard'; export default async function ProfilePage({ searchParams }: AppPageProps) { const user = await getUserFromAuthOrRedirect('/users/sign_in'); @@ -107,6 +108,8 @@ export default async function ProfilePage({ searchParams }: AppPageProps) { + + {params.source && ( 0) { + await revokeGatewayGrantsForBlockedUsers(updated.map(user => user.id)); + } if (rows.length < BATCH_SIZE) { reachedEnd = true; diff --git a/apps/web/src/app/admin/auto-routing/BenchmarksSection.test.ts b/apps/web/src/app/admin/auto-routing/BenchmarksSection.test.ts index 8c1b8ef0d0..ad3a402e67 100644 --- a/apps/web/src/app/admin/auto-routing/BenchmarksSection.test.ts +++ b/apps/web/src/app/admin/auto-routing/BenchmarksSection.test.ts @@ -87,6 +87,7 @@ describe('RoutingTableView', () => { generatedAt: '2026-06-17T00:00:00.000Z', minAccuracy: 0.7, switchCostFactor: 3, + bestAccuracySwitchThreshold: 0.05, source: 'benchmark', routes: { 'implementation/code_generation': [ @@ -124,6 +125,7 @@ describe('RoutingTableView', () => { generatedAt: '2026-06-17T00:00:00.000Z', minAccuracy: 0.7, switchCostFactor: 3, + bestAccuracySwitchThreshold: 0.05, source: 'benchmark', routes: { 'implementation/code_generation': [ @@ -178,6 +180,7 @@ describe('formStateToConfig round-trip', () => { excludedAutoDeciderModels: ['excluded-auto-model'], minAccuracy: 0.8, switchCostFactor: 3, + bestAccuracySwitchThreshold: 0.05, maxConcurrency: 4, benchmarkUserId: 'user-123', benchmarkOrgId: 'org-123', diff --git a/apps/web/src/app/admin/auto-routing/BenchmarksSection.tsx b/apps/web/src/app/admin/auto-routing/BenchmarksSection.tsx index 255cf17d64..621a52e67b 100644 --- a/apps/web/src/app/admin/auto-routing/BenchmarksSection.tsx +++ b/apps/web/src/app/admin/auto-routing/BenchmarksSection.tsx @@ -133,6 +133,7 @@ export function configToFormState(config: BenchmarkConfig | null): { excludedAutoDeciderModels: string; minAccuracy: number; switchCostFactor: number; + bestAccuracySwitchThreshold: number; maxConcurrency: number; benchmarkUserId: string; benchmarkOrgId: string; @@ -152,6 +153,7 @@ export function configToFormState(config: BenchmarkConfig | null): { excludedAutoDeciderModels: '', minAccuracy: 0.7, switchCostFactor: 3, + bestAccuracySwitchThreshold: 0.05, maxConcurrency: 100, benchmarkUserId: '', benchmarkOrgId: '', @@ -172,6 +174,7 @@ export function configToFormState(config: BenchmarkConfig | null): { excludedAutoDeciderModels: (config.excludedAutoDeciderModels ?? []).join('\n'), minAccuracy: config.minAccuracy, switchCostFactor: config.switchCostFactor, + bestAccuracySwitchThreshold: config.bestAccuracySwitchThreshold, maxConcurrency: config.maxConcurrency, benchmarkUserId: config.benchmarkUserId ?? '', benchmarkOrgId: config.benchmarkOrgId ?? '', @@ -246,6 +249,7 @@ export function formStateToConfig( excludedAutoDeciderModels, minAccuracy: state.minAccuracy, switchCostFactor: state.switchCostFactor, + bestAccuracySwitchThreshold: state.bestAccuracySwitchThreshold, maxConcurrency: state.maxConcurrency, benchmarkUserId: benchmarkUserId.length > 0 ? benchmarkUserId : null, benchmarkOrgId: benchmarkOrgId.length > 0 ? benchmarkOrgId : null, @@ -553,6 +557,29 @@ function BenchmarkConfigEditor({ className="h-8 w-40 tabular-nums" />
+
+ + + updateForm(prev => ({ + ...prev, + bestAccuracySwitchThreshold: parseFloat(e.target.value) || 0, + })) + } + className="h-8 w-40 tabular-nums" + /> +