diff --git a/src/server/infra/transport/handlers/config-handler.ts b/src/server/infra/transport/handlers/config-handler.ts index e303041d3..5651cb529 100644 --- a/src/server/infra/transport/handlers/config-handler.ts +++ b/src/server/infra/transport/handlers/config-handler.ts @@ -22,6 +22,7 @@ export class ConfigHandler { this.transport.onRequest(ConfigEvents.GET_ENVIRONMENT, () => { const config = getCurrentConfig() return { + gitRemoteBaseUrl: config.gitRemoteBaseUrl, iamBaseUrl: config.iamBaseUrl, isDevelopment: isDevelopment(), webAppUrl: config.webAppUrl, diff --git a/src/server/infra/transport/handlers/locations-handler.ts b/src/server/infra/transport/handlers/locations-handler.ts index d9578d231..6159c7276 100644 --- a/src/server/infra/transport/handlers/locations-handler.ts +++ b/src/server/infra/transport/handlers/locations-handler.ts @@ -1,3 +1,4 @@ +import {spawn} from 'node:child_process' import {join} from 'node:path' import type {ProjectLocationDTO} from '../../../../shared/transport/types/dto.js' @@ -5,9 +6,15 @@ import type {IContextTreeService} from '../../../core/interfaces/context-tree/i- import type {IProjectRegistry} from '../../../core/interfaces/project/i-project-registry.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' -import {LocationsEvents, type LocationsGetResponse} from '../../../../shared/transport/events/locations-events.js' +import { + LocationsEvents, + type LocationsGetResponse, + type LocationsRevealRequest, + type LocationsRevealResponse, +} from '../../../../shared/transport/events/locations-events.js' import {BRV_DIR, CONTEXT_TREE_DIR} from '../../../constants.js' import {type ProjectPathResolver} from './handler-types.js' +import {resolveRevealCommand} from './reveal-command.js' export interface LocationsHandlerDeps { contextTreeService: IContextTreeService @@ -49,6 +56,10 @@ export class LocationsHandler { return {locations: []} } }) + + this.transport.onRequest(LocationsEvents.REVEAL, async (data) => + this.handleReveal(data), + ) } private async buildLocations(currentProjectPath?: string): Promise { @@ -96,4 +107,28 @@ export class LocationsHandler { return (all.get(b.projectPath)?.registeredAt ?? 0) - (all.get(a.projectPath)?.registeredAt ?? 0) }) } + + private async handleReveal(data: LocationsRevealRequest): Promise { + const {projectPath} = data + if (!projectPath) throw new Error('projectPath is required') + + // Only allow revealing paths that are registered projects — the client + // controls this argument, so we must not trust it blindly. + const registered = this.projectRegistry.getAll() + if (!registered.has(projectPath)) { + throw new Error('Project is not registered.') + } + + const exists = await this.pathExists(projectPath).catch(() => false) + if (!exists) throw new Error('Project folder no longer exists.') + + const {args, command} = resolveRevealCommand(process.platform, projectPath) + const child = spawn(command, args, {detached: true, stdio: 'ignore', windowsHide: true}) + child.on('error', () => { + /* best-effort — nothing to report back once the ack resolved */ + }) + child.unref() + + return {projectPath} + } } diff --git a/src/server/infra/transport/handlers/reveal-command.ts b/src/server/infra/transport/handlers/reveal-command.ts new file mode 100644 index 000000000..717685fe8 --- /dev/null +++ b/src/server/infra/transport/handlers/reveal-command.ts @@ -0,0 +1,14 @@ +/** + * Resolves the OS-native command for revealing a path in the system file manager. + * Extracted so the branching can be unit-tested without spawning real processes. + */ +export type RevealCommand = { + args: string[] + command: string +} + +export function resolveRevealCommand(platformName: NodeJS.Platform, targetPath: string): RevealCommand { + if (platformName === 'darwin') return {args: [targetPath], command: 'open'} + if (platformName === 'win32') return {args: [targetPath], command: 'explorer'} + return {args: [targetPath], command: 'xdg-open'} +} diff --git a/src/server/infra/webui/webui-middleware.ts b/src/server/infra/webui/webui-middleware.ts index b0d4ee27e..8703a36a8 100644 --- a/src/server/infra/webui/webui-middleware.ts +++ b/src/server/infra/webui/webui-middleware.ts @@ -35,7 +35,12 @@ export function createWebUiMiddleware({getConfig, webuiDistDir}: CreateWebUiMidd "connect-src 'self' ws: wss:", "font-src 'self' data: https://fonts.gstatic.com", "frame-ancestors 'none'", - "img-src 'self' data:", + // `img-src https:` is deliberately broad: user avatars come from an + // open-ended set of OAuth provider CDNs (Google, GitHub, Gravatar, + // self-hosted identity providers, …) that can't be enumerated ahead + // of time. Images can't execute code, so the attack surface is just + // pixel exfiltration / tracking, which we accept for this use case. + "img-src 'self' data: https:", "object-src 'none'", "script-src 'self'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", diff --git a/src/shared/transport/events/config-events.ts b/src/shared/transport/events/config-events.ts index de15e0035..0f7cef73f 100644 --- a/src/shared/transport/events/config-events.ts +++ b/src/shared/transport/events/config-events.ts @@ -7,6 +7,7 @@ export const ConfigEvents = { } as const export interface ConfigGetEnvironmentResponse { + gitRemoteBaseUrl: string iamBaseUrl: string isDevelopment: boolean webAppUrl: string diff --git a/src/shared/transport/events/locations-events.ts b/src/shared/transport/events/locations-events.ts index dac4f0548..8ab166c0d 100644 --- a/src/shared/transport/events/locations-events.ts +++ b/src/shared/transport/events/locations-events.ts @@ -2,8 +2,17 @@ import type {ProjectLocationDTO} from '../types/dto.js' export const LocationsEvents = { GET: 'locations:get', + REVEAL: 'locations:reveal', } as const export interface LocationsGetResponse { locations: ProjectLocationDTO[] } + +export interface LocationsRevealRequest { + projectPath: string +} + +export interface LocationsRevealResponse { + projectPath: string +} diff --git a/src/webui/App.tsx b/src/webui/App.tsx index eac594ca1..53f67be02 100644 --- a/src/webui/App.tsx +++ b/src/webui/App.tsx @@ -8,7 +8,7 @@ export function App() { return ( - + ) } diff --git a/src/webui/components/status-dot.tsx b/src/webui/components/status-dot.tsx new file mode 100644 index 000000000..56d46256f --- /dev/null +++ b/src/webui/components/status-dot.tsx @@ -0,0 +1,44 @@ +import {cn} from '@campfirein/byterover-packages/lib/utils' + +type Tone = 'amber' | 'destructive' | 'info' | 'success' + +type Props = { + className?: string + /** When true, wrap the dot in a `ping`-animated halo to draw attention. */ + pulsing?: boolean + tone: Tone +} + +const TONE_BG: Record = { + amber: 'bg-amber-500', + destructive: 'bg-destructive', + info: 'bg-blue-500', + success: 'bg-primary-foreground', +} + +/** + * Small colored dot for status annotations. + * + * - Default: a single solid dot — use for permanent indicators + * (e.g. "connected", "X unread"). + * - `pulsing`: dot wrapped in a `ping`-animated halo — use for exceptions + * that want attention (e.g. "configuration required"). + * + * Default size is 6px; pass `size-*` via `className` to resize. `className` + * can also add border/position utilities (e.g. overlaying as a corner badge + * on an icon). + */ +export function StatusDot({className, pulsing = false, tone}: Props) { + const bg = TONE_BG[tone] + + if (pulsing) { + return ( + + + + + ) + } + + return +} diff --git a/src/webui/features/auth/components/auth-menu.tsx b/src/webui/features/auth/components/auth-menu.tsx index 2aa69bd89..55c184e02 100644 --- a/src/webui/features/auth/components/auth-menu.tsx +++ b/src/webui/features/auth/components/auth-menu.tsx @@ -24,7 +24,7 @@ function UnauthorizedTrigger() { return ( <> - @@ -89,7 +89,7 @@ export function AuthMenu() { if (isLoadingInitial) { return ( - ) diff --git a/src/webui/features/auth/components/login-dialog.tsx b/src/webui/features/auth/components/login-dialog.tsx index 3a8fc499d..fe069d73d 100644 --- a/src/webui/features/auth/components/login-dialog.tsx +++ b/src/webui/features/auth/components/login-dialog.tsx @@ -51,7 +51,7 @@ export function LoginDialog({onOpenChange, open}: LoginDialogProps) { const unsubscribe = subscribeToLoginCompleted((data) => { if (data.success && data.user) { - toast.success(`Logged in as ${data.user.email}`, {position: 'top-center'}) + toast.success(`Logged in as ${data.user.email}`) queryClient.invalidateQueries({queryKey: getAuthStateQueryOptions().queryKey}) onOpenChange(false) } else { @@ -76,7 +76,7 @@ export function LoginDialog({onOpenChange, open}: LoginDialogProps) { const result = await queryClient.fetchQuery(getAuthStateQueryOptions()) if (cancelled) return if (result.isAuthorized && result.user) { - toast.success(`Logged in as ${result.user.email}`, {position: 'top-center'}) + toast.success(`Logged in as ${result.user.email}`) setLoggingIn(false) onOpenChange(false) } diff --git a/src/webui/features/connectors/components/connectors-panel.tsx b/src/webui/features/connectors/components/connectors-panel.tsx index e538e2cf8..6252f10c2 100644 --- a/src/webui/features/connectors/components/connectors-panel.tsx +++ b/src/webui/features/connectors/components/connectors-panel.tsx @@ -1,19 +1,25 @@ -import { Button } from '@campfirein/byterover-packages/components/button' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@campfirein/byterover-packages/components/dropdown-menu' -import { ChevronDown, LoaderCircle, Plus } from 'lucide-react' -import { useState } from 'react' -import { toast } from 'sonner' +import {Button} from '@campfirein/byterover-packages/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@campfirein/byterover-packages/components/dropdown-menu' +import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' +import {ChevronDown, LoaderCircle, Plus} from 'lucide-react' +import {useState} from 'react' +import {toast} from 'sonner' -import type { ConnectorDTO } from '../../../../shared/transport/types/dto' -import type { Agent } from '../../../../shared/types/agent' -import type { ConnectorType } from '../../../../shared/types/connector-type' +import type {ConnectorDTO} from '../../../../shared/transport/types/dto' +import type {Agent} from '../../../../shared/types/agent' +import type {ConnectorType} from '../../../../shared/types/connector-type' -import { requiresAgentRestart } from '../../../../shared/types/connector-type' -import { useGetAgents } from '../api/get-agents' -import { useGetConnectors } from '../api/get-connectors' -import { useInstallConnector } from '../api/install-connector' -import { AddConnectorDialog } from './add-connector-dialog' -import { agentIcons } from './agent-icons' +import {requiresAgentRestart} from '../../../../shared/types/connector-type' +import {useGetAgents} from '../api/get-agents' +import {useGetConnectors} from '../api/get-connectors' +import {useInstallConnector} from '../api/install-connector' +import {AddConnectorDialog} from './add-connector-dialog' +import {agentIcons} from './agent-icons' const connectorLabels: Record = { hook: 'Hook', @@ -23,8 +29,8 @@ const connectorLabels: Record = { } export function ConnectorsPanel() { - const { data: connectorsData, isLoading: isLoadingConnectors } = useGetConnectors() - const { data: agentsData, isLoading: isLoadingAgents } = useGetAgents() + const {data: connectorsData, isLoading: isLoadingConnectors} = useGetConnectors() + const {data: agentsData, isLoading: isLoadingAgents} = useGetAgents() const installMutation = useInstallConnector() const [addDialogOpen, setAddDialogOpen] = useState(false) const [pendingAgentId, setPendingAgentId] = useState() @@ -37,7 +43,7 @@ export function ConnectorsPanel() { if (newType === connector.connectorType) return try { - await installMutation.mutateAsync({ agentId: connector.agent, connectorType: newType }) + await installMutation.mutateAsync({agentId: connector.agent, connectorType: newType}) const needsRestart = requiresAgentRestart(newType) toast.success( needsRestart @@ -51,33 +57,30 @@ export function ConnectorsPanel() { const handleAddConnector = async (agentId: Agent, connectorType: ConnectorType) => { try { - await installMutation.mutateAsync({ agentId, connectorType }) + await installMutation.mutateAsync({agentId, connectorType}) toast.success(`${agentId} connected via ${connectorLabels[connectorType]}.`) } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to install connector') } } - if (isLoading) { - return ( -
- -
- ) - } - return ( -
+
{/* Title + Add button */}
-

Connectors

-

Manage how AI agents connect to ByteRover.

+

Connectors

+

+ Manage how AI agents connect to ByteRover. +

- +
+ {isLoading && } + +
{/* Connector list */} - {connectors.length === 0 ? ( -

No connectors installed. Add one to get started.

+ {isLoading ? ( +
+
+ + + +
+
+ ) : connectors.length === 0 ? ( +

+ No connectors installed. Add one to get started. +

) : ( -
{ - connectors.map((connector, index) => ( -
-
-
+
+ {connectors.map((connector, index) => ( +
+
+
{agentIcons[connector.agent] ? ( ) : (
)} -
+
{connector.agent} - Connected + Connected
-
+
} + render={ +
- {index < connectors.length - 1 &&
} + {index < connectors.length - 1 &&
}
- )) - }
+ ))} +
)}
) diff --git a/src/webui/features/context/components/context-detail-panel.tsx b/src/webui/features/context/components/context-detail-panel.tsx index fa82276bd..d57984a6a 100644 --- a/src/webui/features/context/components/context-detail-panel.tsx +++ b/src/webui/features/context/components/context-detail-panel.tsx @@ -7,6 +7,7 @@ import { useMemo } from 'react' import type { ContextNode } from '../types' +import { noop } from '../../../lib/noop' import { useGetContextFileMetadata } from '../api/get-context-file-metadata' import { useGetContextHistory } from '../api/get-context-history' import { useContextTree } from '../hooks/use-context-tree' @@ -14,8 +15,6 @@ import { isFilePath } from '../utils/tree-utils' import { ContextBreadcrumb } from './context-breadcrumb' import { MarkdownView } from './markdown-view' -const NOOP = () => { } - interface ContextDetailPanelProps { onToggleHistory?: () => void } @@ -136,7 +135,7 @@ export function ContextDetailPanel({ onToggleHistory }: ContextDetailPanelProps) onContentChange={setEditContent} onEnterEditMode={enterEditMode} onSaveChanges={saveChanges} - onToggleHistory={onToggleHistory ?? NOOP} + onToggleHistory={onToggleHistory ?? noop} showTags={false} tags={fileData?.tags} timeline={ diff --git a/src/webui/features/onboarding/components/help-menu.tsx b/src/webui/features/onboarding/components/help-menu.tsx index 981fc20a4..2bdee00e4 100644 --- a/src/webui/features/onboarding/components/help-menu.tsx +++ b/src/webui/features/onboarding/components/help-menu.tsx @@ -7,7 +7,7 @@ import { DropdownMenuTrigger, } from '@campfirein/byterover-packages/components/dropdown-menu' import {cn} from '@campfirein/byterover-packages/lib/utils' -import {BookOpen, Bug, HelpCircle, PlayCircle} from 'lucide-react' +import {BookOpen, Bug, LifeBuoy, PlayCircle} from 'lucide-react' import {useOnboardingStore} from '../stores/onboarding-store' @@ -25,7 +25,7 @@ export function HelpMenu() { - + Help {showHint && } diff --git a/src/webui/features/onboarding/components/tour-backdrop.tsx b/src/webui/features/onboarding/components/tour-backdrop.tsx new file mode 100644 index 000000000..b10865023 --- /dev/null +++ b/src/webui/features/onboarding/components/tour-backdrop.tsx @@ -0,0 +1,26 @@ +import {useOnboardingStore} from '../stores/onboarding-store' + +/** + * Page-wide dim + blur during the curate/query tour steps. Sits beneath the + * tour bar (z-100) and beneath any TourPointer-wrapped target (z-50), so the + * highlighted controls stay sharp while the rest of the UI fades back. + * + * Active on every route (not just `/tasks`) because the Tasks-tab coachmark + * lives in the global header — when the user is on a different page, the + * backdrop draws focus to that coachmark too. + * + * Click-blocking is intentional: the rest of the page is clearly out of + * focus, and we don't want a stray click on a blurred Configuration tab to + * yank the user away from the tour. They exit via the TourBar. + */ +export function TourBackdrop() { + const tourActive = useOnboardingStore((s) => s.tourActive) + const tourStep = useOnboardingStore((s) => s.tourStep) + const tourTaskId = useOnboardingStore((s) => s.tourTaskId) + + const inComposerStep = tourStep === 'curate' || tourStep === 'query' + const show = tourActive && inComposerStep && !tourTaskId + if (!show) return null + + return
+} diff --git a/src/webui/features/onboarding/components/tour-host.tsx b/src/webui/features/onboarding/components/tour-host.tsx index 7f1462ac1..48448daea 100644 --- a/src/webui/features/onboarding/components/tour-host.tsx +++ b/src/webui/features/onboarding/components/tour-host.tsx @@ -1,25 +1,16 @@ /** * Tour host * - * Mounted once at the layout level. Reads the tour step from the onboarding - * store and renders the right "tour-driven" surface for that step: - * - * provider → ProviderFlowDialog (auto-advances via use-tour-watchers when - * an active provider becomes configured) - * curate → TaskComposerSheet prefilled with a curate example (advances - * on successful submit via the sheet's onSubmitted callback) - * query → same, prefilled with a query example - * connector → ConnectorStep (advances on the user's "Done" click) - * - * Tour-aware UI (the step pill on the provider dialog, etc.) lives inside the - * relevant components themselves and toggles on `useOnboardingStore.tourStep`. + * Mounted once at the layout level. Renders surfaces that are *fully owned* + * by the tour FSM — the provider dialog (step 1) and the connector step + * (step 4). Steps 2/3 (curate/query) intentionally do not auto-mount the + * composer here: `useTourWatchers` routes the user to `/tasks`, where the + * empty-state coachmark guides them to click "New task" themselves. The + * normal-mode `TaskComposerSheet` then opens with tour-aware prefill (see + * `TaskListView`). */ -import {useRef} from 'react' -import {useNavigate} from 'react-router-dom' - import {ProviderFlowDialog} from '../../provider/components/provider-flow' -import {TaskComposerSheet} from '../../tasks/components/task-composer' import {useOnboardingStore} from '../stores/onboarding-store' import {ConnectorStep} from './connector-step' @@ -30,33 +21,14 @@ function snapshotIsProviderStep() { return useOnboardingStore.getState().tourStep === 'provider' } -const CURATE_EXAMPLE = - 'JWT tokens expire after 24h. Refresh window is 7 days. Rotation happens on every successful refresh — old refresh token is invalidated immediately.' -const QUERY_EXAMPLE = 'What is our auth token expiration policy?' - export function TourHost() { const tourActive = useOnboardingStore((s) => s.tourActive) const tourStep = useOnboardingStore((s) => s.tourStep) - const tourTaskId = useOnboardingStore((s) => s.tourTaskId) const exitTour = useOnboardingStore((s) => s.exitTour) const advanceTour = useOnboardingStore((s) => s.advanceTour) - const setTourTaskId = useOnboardingStore((s) => s.setTourTaskId) - const navigate = useNavigate() - - // The composer fires onSubmitted *and then* onClose synchronously after a - // successful submit. Without this guard, onClose would call exitTour() right - // after onSubmitted set the tour task — and exitTour would win the batched - // setState. The flag is set in onSubmitted and consumed in onClose so only - // user-initiated closes exit the tour. - const submittedRef = useRef(false) if (!tourActive || !tourStep) return null - // While a tour task is in flight (curate/query submitted, awaiting completion) - // keep the composer closed so the user can watch the task run in the detail - // view. The Continue CTA in the detail view advances the tour. - const showComposer = (tourStep === 'curate' || tourStep === 'query') && !tourTaskId - return ( <> {tourStep === 'provider' && ( @@ -74,36 +46,6 @@ export function TourHost() { /> )} - {showComposer && ( - { - if (submittedRef.current) { - submittedRef.current = false - return - } - - exitTour() - }} - onSubmitted={(taskId) => { - submittedRef.current = true - // Tour stays on the current step; record the in-flight task and - // navigate to its detail. The user advances via the Continue CTA - // once the task completes. - setTourTaskId(taskId) - navigate(`/tasks?task=${taskId}`) - }} - open - prefillNotice="example" - tourStepLabel={tourStep === 'curate' ? 'Step 2 of 4' : 'Step 3 of 4'} - /> - )} - ) diff --git a/src/webui/features/onboarding/components/tour-pointer.tsx b/src/webui/features/onboarding/components/tour-pointer.tsx new file mode 100644 index 000000000..12e39246d --- /dev/null +++ b/src/webui/features/onboarding/components/tour-pointer.tsx @@ -0,0 +1,133 @@ +import {cn} from '@campfirein/byterover-packages/lib/utils' +import {type ReactNode} from 'react' + +type Side = 'bottom' | 'top' +type Align = 'center' | 'end' | 'start' + +type Props = { + /** + * When false the wrapped child is rendered untouched, so callers can drop + * into existing markup without conditionals. + */ + active: boolean + align?: Align + children: ReactNode + className?: string + label: string + side?: Side +} + +/** + * A gentle curved arrow connecting the label to the highlighted target. + * Hand-drawn feel — slightly bowed line + arrowhead, ~32px long so the + * label has room to breathe above/below the target. + */ +type CurveFrom = 'left' | 'right' + +function CurvedArrow({ + className, + curveFrom, + direction, +}: { + className?: string + curveFrom: CurveFrom + direction: 'down' | 'up' +}) { + // The stick's source side flips so it always curves from where the label + // sits toward the target's tip — otherwise the curve "points away" from + // the label and the assembly looks disjointed. + const fromRight = curveFrom === 'right' + return ( + + {direction === 'up' ? ( + fromRight ? ( + <> + + + + ) : ( + <> + + + + ) + ) : fromRight ? ( + <> + + + + ) : ( + <> + + + + )} + + ) +} + +/** + * Onboarding coachmark. Wraps a target with a soft primary-tinted glow, + * with a small label connected by a curved arrow that points at the + * highlighted control. Static — attention comes from the glow + the + * directional arrow rather than motion. + */ +export function TourPointer({active, align = 'center', children, className, label, side = 'bottom'}: Props) { + if (!active) return <>{children} + + // Curve the stick from the side the label *visually sits on* — not the + // side of its anchor. For `align="end"` the label is right-anchored + // (`right-0`) but `whitespace-nowrap` makes it extend LEFT from the + // target's right edge, so it sits on the LEFT side of the target's + // center; `curveFrom: 'left'` makes the stick start at the left side of + // the SVG (viewBox x=6) so the curve flows label → tip without doubling + // back. `align="start"` is the inverse. `align="center"` is symmetric, + // so we default to right. + const curveFrom: CurveFrom = align === 'end' ? 'left' : 'right' + + return ( + // z-50 lifts the target above the page-wide TourBackdrop (z-40) so the + // highlighted control stays sharp while everything else fades back. + + + {children} + + + {/* Arrow always pinned to the target's horizontal center so the tip + lands on the highlighted control. */} + + + + + {/* Label aligned independently — for `align="end"` the label sits to + the left of the arrow tail, etc. — so the assembly never overflows + past the target's edge. */} + + {label} + + + ) +} diff --git a/src/webui/features/onboarding/components/tour-step-badge.tsx b/src/webui/features/onboarding/components/tour-step-badge.tsx index 840aec711..08315470c 100644 --- a/src/webui/features/onboarding/components/tour-step-badge.tsx +++ b/src/webui/features/onboarding/components/tour-step-badge.tsx @@ -8,7 +8,10 @@ import {Badge} from '@campfirein/byterover-packages/components/badge' export function TourStepBadge({label}: {label: string}) { return ( {label} diff --git a/src/webui/features/onboarding/components/tour-task-banner.tsx b/src/webui/features/onboarding/components/tour-task-banner.tsx index 14041c14c..c34c347f9 100644 --- a/src/webui/features/onboarding/components/tour-task-banner.tsx +++ b/src/webui/features/onboarding/components/tour-task-banner.tsx @@ -3,7 +3,6 @@ import {ArrowRight, Check} from 'lucide-react' import type {StoredTask} from '../../tasks/types/stored-task' -import {isTerminalStatus} from '../../tasks/utils/task-status' import {useOnboardingStore} from '../stores/onboarding-store' import {TourStepBadge} from './tour-step-badge' @@ -38,6 +37,13 @@ function useActiveTourTask(task: StoredTask) { return isMatch ? (tourStep as 'curate' | 'query') : null } +function bannerHint(status: StoredTask['status'], step: 'curate' | 'query'): string { + if (status === 'completed') return 'Task done. Scroll for the Continue button.' + if (status === 'error') return 'Task failed. Use Try again below or fix the provider config.' + if (status === 'cancelled') return 'Task cancelled. Use Try again below to retry.' + return RUNNING_HINT[step] +} + /** * Top-of-detail banner. Pins the tour step pill + a brief running hint above * the task content so the user knows they're still in the tour. @@ -49,21 +55,20 @@ export function TourTaskBanner({task}: {task: StoredTask}) { return (
- - {isTerminalStatus(task.status) ? 'Task done. Scroll for the Continue button.' : RUNNING_HINT[step]} - + {bannerHint(task.status, step)}
) } /** - * Bottom-of-detail CTA. Only renders once the task reaches a terminal state — - * the user has had a chance to scroll through events and see the result. + * Bottom-of-detail CTA. Only renders on a successful completion — failed and + * cancelled tasks need to be retried before the tour can advance, so we let + * the ErrorSection's "Try again" CTA carry the action and stay silent here. */ export function TourTaskContinueCta({task}: {task: StoredTask}) { const advanceTour = useOnboardingStore((s) => s.advanceTour) const step = useActiveTourTask(task) - if (!step || !isTerminalStatus(task.status)) return null + if (!step || task.status !== 'completed') return null return (
diff --git a/src/webui/features/onboarding/hooks/use-tour-watchers.ts b/src/webui/features/onboarding/hooks/use-tour-watchers.ts index 6eae580c9..50ca83b05 100644 --- a/src/webui/features/onboarding/hooks/use-tour-watchers.ts +++ b/src/webui/features/onboarding/hooks/use-tour-watchers.ts @@ -1,19 +1,31 @@ import {useEffect} from 'react' +import {useSearchParams} from 'react-router-dom' import {useGetActiveProviderConfig} from '../../provider/api/get-active-provider-config' import {useOnboardingStore} from '../stores/onboarding-store' /** - * Watches store/state transitions that should auto-advance the tour. + * Watches store/state transitions that should auto-advance the tour and run + * any side-effects that the FSM doesn't model directly. * - * Currently: when the user finishes provider setup (active provider config - * becomes available), advance from the `provider` step to `curate`. The other - * steps advance on direct user action (composer submit, connector "Done"). + * - `provider → curate` auto-advances when the active provider config + * becomes available. + * - On entering `query`, close any open task detail (`?task=…`) — otherwise + * the just-finished curate task's detail sheet would still be open and + * would hide the FilterBar's "New task" button that the next coachmark + * points at. + * + * Curate and query advance on direct user action via the composer's + * `onSubmitted`. The Tasks-tab coachmark is what guides the user across the + * tab boundary — we deliberately don't auto-navigate so the click itself + * becomes the teaching moment. */ export function useTourWatchers() { const tourActive = useOnboardingStore((s) => s.tourActive) const tourStep = useOnboardingStore((s) => s.tourStep) + const tourTaskId = useOnboardingStore((s) => s.tourTaskId) const advanceTour = useOnboardingStore((s) => s.advanceTour) + const [, setSearchParams] = useSearchParams() const {data: activeConfig} = useGetActiveProviderConfig({ queryConfig: {enabled: tourActive && tourStep === 'provider'}, @@ -23,4 +35,22 @@ export function useTourWatchers() { if (!tourActive || tourStep !== 'provider') return if (activeConfig?.activeModel) advanceTour() }, [tourActive, tourStep, activeConfig?.activeModel, advanceTour]) + + useEffect(() => { + if (!tourActive || tourStep !== 'query') return + // Only strip when no tour task is in flight — `advanceTour` clears + // `tourTaskId` on transition, so this catches the curate→query moment. + // After the user submits a query and `tourTaskId` is set again, we + // bail out so the new query's detail sheet stays open. + if (tourTaskId) return + setSearchParams( + (prev) => { + if (!prev.has('task')) return prev + const next = new URLSearchParams(prev) + next.delete('task') + return next + }, + {replace: true}, + ) + }, [tourActive, tourStep, tourTaskId, setSearchParams]) } diff --git a/src/webui/features/onboarding/lib/tour-examples.ts b/src/webui/features/onboarding/lib/tour-examples.ts new file mode 100644 index 000000000..143bab3ad --- /dev/null +++ b/src/webui/features/onboarding/lib/tour-examples.ts @@ -0,0 +1,9 @@ +export const CURATE_EXAMPLE = + 'List the most important conventions and patterns used in this codebase — naming, file organization, testing approach, and any rules a new contributor should know before making changes.' + +export const QUERY_EXAMPLE = 'What conventions should I follow when making changes?' + +export const TOUR_STEP_LABEL = { + curate: 'Step 2 of 4', + query: 'Step 3 of 4', +} as const diff --git a/src/webui/features/project/api/reveal-project-folder.ts b/src/webui/features/project/api/reveal-project-folder.ts new file mode 100644 index 000000000..ae2ff88da --- /dev/null +++ b/src/webui/features/project/api/reveal-project-folder.ts @@ -0,0 +1,26 @@ +import {useMutation} from '@tanstack/react-query' + +import type {MutationConfig} from '../../../lib/react-query' + +import { + LocationsEvents, + type LocationsRevealRequest, + type LocationsRevealResponse, +} from '../../../../shared/transport/events/locations-events' +import {useTransportStore} from '../../../stores/transport-store' + +export const revealProjectFolder = (input: LocationsRevealRequest): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + return apiClient.request(LocationsEvents.REVEAL, input) +} + +type UseRevealProjectFolderOptions = { + mutationConfig?: MutationConfig +} + +export const useRevealProjectFolder = ({mutationConfig}: UseRevealProjectFolderOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: revealProjectFolder, + }) diff --git a/src/webui/features/project/components/project-dropdown.tsx b/src/webui/features/project/components/project-dropdown.tsx index 15833b5d3..36dbe111e 100644 --- a/src/webui/features/project/components/project-dropdown.tsx +++ b/src/webui/features/project/components/project-dropdown.tsx @@ -20,14 +20,16 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@campfirein/byterover-packages/components/dropdown-menu' -import {ArrowRightLeft, ChevronDown, FolderOpen, SquareArrowOutUpRight} from 'lucide-react' +import {ArrowRightLeft, ChevronDown, FolderOpen, FolderSearch, SquareArrowOutUpRight} from 'lucide-react' import {useMemo, useState} from 'react' +import {toast} from 'sonner' import {ProjectLocationDTO} from '../../../../shared/transport/events' import {useTransportStore} from '../../../stores/transport-store' import {isSafeHttpUrl} from '../../auth/utils/is-safe-http-url' import {useGetProjectConfig} from '../api/get-project-config' import {useGetProjectList} from '../api/get-project-list' +import {useRevealProjectFolder} from '../api/reveal-project-folder' import {displayPath} from '../utils/display-path' import {getProjectName} from '../utils/project-name' import {AllProjectsDialog} from './all-projects-dialog' @@ -97,14 +99,25 @@ type OpenProjectItemProps = { function OpenProjectItem({isSelected, onSelect, project}: OpenProjectItemProps) { const name = getProjectName(project.projectPath) const {data: projectConfig} = useGetProjectConfig({projectPath: project.projectPath}) + const reveal = useRevealProjectFolder() const teamName = projectConfig?.brvConfig?.teamName const spaceName = projectConfig?.brvConfig?.spaceName const remoteLabel = teamName && spaceName ? `${teamName} / ${spaceName}` : undefined // Only open http(s) URLs — SSH remotes (git@host:…) can't open in a browser, // and a tampered `.git/config` could otherwise smuggle `javascript:` / `file:` URIs. - const openableRemoteUrl = projectConfig?.remoteUrl && isSafeHttpUrl(projectConfig.remoteUrl) - ? projectConfig.remoteUrl - : undefined + const openableRemoteUrl = + projectConfig?.remoteUrl && isSafeHttpUrl(projectConfig.remoteUrl) ? projectConfig.remoteUrl : undefined + + function handleRevealLocal() { + reveal.mutate( + {projectPath: project.projectPath}, + { + onError(error) { + toast.error(error instanceof Error ? error.message : 'Failed to open folder.') + }, + }, + ) + } return ( @@ -125,6 +138,10 @@ function OpenProjectItem({isSelected, onSelect, project}: OpenProjectItemProps) Open Remote space + + + Open local folder + ) diff --git a/src/webui/features/provider/components/global-provider-dialog.tsx b/src/webui/features/provider/components/global-provider-dialog.tsx new file mode 100644 index 000000000..7babe24c2 --- /dev/null +++ b/src/webui/features/provider/components/global-provider-dialog.tsx @@ -0,0 +1,15 @@ +import {useProviderStore} from '../stores/provider-store' +import {ProviderFlowDialog} from './provider-flow' + +/** + * Store-backed mount of ProviderFlowDialog so any component can open it + * without owning its own dialog state. Triggered via + * `useProviderStore.getState().openProviderDialog()` (or a selector). + * Existing local-state mounts (Header, TaskComposer, TourHost) keep working. + */ +export function GlobalProviderDialog() { + const isOpen = useProviderStore((s) => s.isDialogOpen) + const closeProviderDialog = useProviderStore((s) => s.closeProviderDialog) + + return !open && closeProviderDialog()} open={isOpen} /> +} diff --git a/src/webui/features/provider/components/provider-flow/login-prompt-step.tsx b/src/webui/features/provider/components/provider-flow/login-prompt-step.tsx index 79ae07438..1ea4ca8b3 100644 --- a/src/webui/features/provider/components/provider-flow/login-prompt-step.tsx +++ b/src/webui/features/provider/components/provider-flow/login-prompt-step.tsx @@ -1,32 +1,106 @@ import {Button} from '@campfirein/byterover-packages/components/button' import {DialogFooter, DialogHeader, DialogTitle} from '@campfirein/byterover-packages/components/dialog' import {useQueryClient} from '@tanstack/react-query' -import {ChevronLeft, LoaderCircle} from 'lucide-react' -import {useEffect, useState} from 'react' +import {ChevronLeft, ExternalLink, LoaderCircle} from 'lucide-react' +import {useEffect, useRef, useState} from 'react' import {getAuthStateQueryOptions} from '../../../auth/api/get-auth-state' import {login, subscribeToLoginCompleted} from '../../../auth/api/login' import {useAuthStore} from '../../../auth/stores/auth-store' import {isSafeHttpUrl} from '../../../auth/utils/is-safe-http-url' +/** + * The Window reference returned by window.open, expressed without naming the + * DOM type directly (ESLint's no-undef doesn't ship with browser globals). + */ +type PopupRef = ReturnType + interface LoginPromptStepProps { onAuthenticated: () => void onBack: () => void + /** + * The OAuth popup. ProviderSelectStep opens it synchronously from the row + * click (user-gesture context), then hands it here for the step to navigate + * once the auth URL is ready. + */ + popup: PopupRef } type InnerState = + | {authUrl: string; type: 'blocked'} | {authUrl: string; type: 'waiting'} | {message: string; type: 'error'} - | {type: 'idle'} | {type: 'starting'} const POLL_INTERVAL_MS = 2500 +/** + * Minimum time the "Signing in to ByteRover" dialog stays visible before we + * navigate the popup. Keeps the transition legible — without it the popup + * races to the auth URL before the user sees the step. + */ +const MIN_VISIBLE_DELAY_MS = 800 + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} -export function LoginPromptStep({onAuthenticated, onBack}: LoginPromptStepProps) { +export function LoginPromptStep({onAuthenticated, onBack, popup}: LoginPromptStepProps) { const queryClient = useQueryClient() const isAuthorized = useAuthStore((s) => s.isAuthorized) const setLoggingIn = useAuthStore((s) => s.setLoggingIn) - const [state, setState] = useState({type: 'idle'}) + const [state, setState] = useState({type: 'starting'}) + const [retryCount, setRetryCount] = useState(0) + const didStartRef = useRef(false) + + // Kick off the OAuth request as soon as the step mounts. The popup was + // already opened synchronously in the row click handler upstream. + useEffect(() => { + if (didStartRef.current) return + didStartRef.current = true + setLoggingIn(true) + let cancelled = false + + async function start() { + try { + const [response] = await Promise.all([login(), sleep(MIN_VISIBLE_DELAY_MS)]) + if (cancelled) return + if (!isSafeHttpUrl(response.authUrl)) { + popup?.close() + setState({message: 'Received an unsafe OAuth URL from the daemon', type: 'error'}) + setLoggingIn(false) + return + } + + if (popup && !popup.closed) { + popup.location.href = response.authUrl + setState({authUrl: response.authUrl, type: 'waiting'}) + } else { + // Popup was blocked or closed before navigation. Fall back to an + // explicit user-initiated open via the action button below. + setState({authUrl: response.authUrl, type: 'blocked'}) + } + } catch (error) { + if (cancelled) return + setLoggingIn(false) + setState({ + message: error instanceof Error ? error.message : 'Unable to start login', + type: 'error', + }) + } + } + + start().catch(() => { + // error already surfaced via state + }) + + return () => { + cancelled = true + } + // `retryCount` is a trigger, not read inside the effect — it forces a + // re-run when the user hits "Retry sign-in" after a failure. + }, [popup, setLoggingIn, retryCount]) // Auto-continue once auth flips to authorized (from LOGIN_COMPLETED or poll). useEffect(() => { @@ -52,7 +126,6 @@ export function LoginPromptStep({onAuthenticated, onBack}: LoginPromptStepProps) return unsubscribe }, [queryClient, setLoggingIn, state.type]) - // Fallback poll in case LOGIN_COMPLETED is missed. useEffect(() => { if (state.type !== 'waiting') return @@ -78,36 +151,12 @@ export function LoginPromptStep({onAuthenticated, onBack}: LoginPromptStepProps) } }, [queryClient, setLoggingIn, state.type]) - async function handleSignIn() { - setLoggingIn(true) - // Open the popup synchronously to keep the user-gesture context — browsers - // block window.open() if it lands inside an async callback. `noopener` is - // intentionally omitted so we get a window reference and can navigate it - // once the auth URL arrives. - const popup = window.open('about:blank', '_blank') + function retry() { + // Clear the guard and bump `retryCount` so the start effect re-runs — + // state alone isn't in its deps list, so setState isn't enough. + didStartRef.current = false setState({type: 'starting'}) - - try { - const response = await login() - if (!isSafeHttpUrl(response.authUrl)) { - popup?.close() - throw new Error('Received an unsafe OAuth URL from the daemon') - } - - if (popup && !popup.closed) { - popup.location.href = response.authUrl - } else { - window.open(response.authUrl, '_blank', 'noopener,noreferrer') - } - - setState({authUrl: response.authUrl, type: 'waiting'}) - } catch (error) { - setLoggingIn(false) - setState({ - message: error instanceof Error ? error.message : 'Unable to start login', - type: 'error', - }) - } + setRetryCount((n) => n + 1) } return ( @@ -117,27 +166,22 @@ export function LoginPromptStep({onAuthenticated, onBack}: LoginPromptStepProps) - Sign in to ByteRover + Signing in to ByteRover
-

- ByteRover requires authentication before it can be used as a provider. Sign in to your{' '} - byterover.dev account to continue. -

- {state.type === 'starting' && ( -
- - Starting authentication… +
+ + Preparing sign-in…
)} {state.type === 'waiting' && ( -
+
- + Finish signing in in the new tab.
@@ -150,6 +194,13 @@ export function LoginPromptStep({onAuthenticated, onBack}: LoginPromptStepProps)
)} + {state.type === 'blocked' && ( +
+ + Your browser blocked the sign-in popup. +
+ )} + {state.type === 'error' && (
{state.message}
)} @@ -157,15 +208,23 @@ export function LoginPromptStep({onAuthenticated, onBack}: LoginPromptStepProps) - {state.type === 'error' ? ( - - ) : ( - } + {state.type === 'blocked' && ( + )} + {(state.type === 'starting' || state.type === 'waiting') && ( + + )}
) diff --git a/src/webui/features/provider/components/provider-flow/provider-flow-dialog.tsx b/src/webui/features/provider/components/provider-flow/provider-flow-dialog.tsx index a1722efe8..2bbd3b829 100644 --- a/src/webui/features/provider/components/provider-flow/provider-flow-dialog.tsx +++ b/src/webui/features/provider/components/provider-flow/provider-flow-dialog.tsx @@ -1,30 +1,38 @@ -import { Dialog, DialogContent } from '@campfirein/byterover-packages/components/dialog' -import { LoaderCircle } from 'lucide-react' -import { useCallback, useState } from 'react' -import { toast } from 'sonner' +import {Dialog, DialogContent} from '@campfirein/byterover-packages/components/dialog' +import {LoaderCircle} from 'lucide-react' +import {useCallback, useRef, useState} from 'react' +import {toast} from 'sonner' import type {ModelDTO, ProviderDTO} from '../../../../../shared/transport/events' -import { formatError } from '../../../../lib/error-messages' -import { useAuthStore } from '../../../auth/stores/auth-store' -import { useSetActiveModel } from '../../../model/api/set-active-model' +import {formatError} from '../../../../lib/error-messages' +import {useAuthStore} from '../../../auth/stores/auth-store' +import {useSetActiveModel} from '../../../model/api/set-active-model' import {TourStepBadge} from '../../../onboarding/components/tour-step-badge' -import { useAwaitOAuthCallback } from '../../api/await-oauth-callback' -import { useConnectProvider } from '../../api/connect-provider' -import { useDisconnectProvider } from '../../api/disconnect-provider' -import { useGetProviders } from '../../api/get-providers' -import { useSetActiveProvider } from '../../api/set-active-provider' -import { useStartOAuth } from '../../api/start-oauth' -import { useValidateApiKey } from '../../api/validate-api-key' -import { ApiKeyStep } from './api-key-step' -import { AuthMethodStep } from './auth-method-step' -import { BaseUrlStep } from './base-url-step' -import { LoginPromptStep } from './login-prompt-step' -import { ModelSelectStep } from './model-select-step' -import { type ProviderActionId, ProviderActionStep } from './provider-action-step' -import { ProviderSelectStep } from './provider-select-step' - -type FlowStep = 'api_key' | 'auth_method' | 'base_url' | 'connecting' | 'login_prompt' | 'model_select' | 'provider_actions' | 'select' +import {useAwaitOAuthCallback} from '../../api/await-oauth-callback' +import {useConnectProvider} from '../../api/connect-provider' +import {useDisconnectProvider} from '../../api/disconnect-provider' +import {useGetProviders} from '../../api/get-providers' +import {useSetActiveProvider} from '../../api/set-active-provider' +import {useStartOAuth} from '../../api/start-oauth' +import {useValidateApiKey} from '../../api/validate-api-key' +import {ApiKeyStep} from './api-key-step' +import {AuthMethodStep} from './auth-method-step' +import {BaseUrlStep} from './base-url-step' +import {LoginPromptStep} from './login-prompt-step' +import {ModelSelectStep} from './model-select-step' +import {type ProviderActionId, ProviderActionStep} from './provider-action-step' +import {ProviderSelectStep} from './provider-select-step' + +type FlowStep = + | 'api_key' + | 'auth_method' + | 'base_url' + | 'connecting' + | 'login_prompt' + | 'model_select' + | 'provider_actions' + | 'select' const BYTEROVER_PROVIDER_ID = 'byterover' @@ -62,6 +70,12 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou const [error, setError] = useState() const [isNewConnection, setIsNewConnection] = useState(false) + // Window reference for the ByteRover OAuth popup. Opened synchronously in the + // provider row click handler to preserve the user-gesture context (browsers + // block popups opened later from effects or awaited promises) and handed off + // to LoginPromptStep, which navigates it to the auth URL. + const oauthPopupRef = useRef>(null) + const isAuthorized = useAuthStore((s) => s.isAuthorized) const {data} = useGetProviders() const connectMutation = useConnectProvider() @@ -135,8 +149,12 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou setSelectedProvider(provider) setError(undefined) - // ByteRover requires sign-in first + // ByteRover requires sign-in first. Open the OAuth popup synchronously + // right here — we're inside the row's click handler, which is still + // within the user-gesture window browsers require for window.open(). + // Opening later (from useEffect or after await) gets blocked. if (provider.id === BYTEROVER_PROVIDER_ID && !isAuthorized) { + oauthPopupRef.current = window.open('about:blank', '_blank') setStep('login_prompt') return } @@ -178,58 +196,63 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou return } - // No key needed → connect directly → model select - setStep('connecting') - try { - await connectMutation.mutateAsync({ providerId: provider.id }) - setIsNewConnection(true) - setStep('model_select') - } catch (error_) { - toast.error(formatError(error_, 'Connection failed')) - setStep('select') - } - }, [connectByteRover, connectMutation, isAuthorized, onProviderActivated, resetAndClose]) - - const handleOAuth = useCallback(async (provider: ProviderDTO) => { - setStep('connecting') - try { - const result = await startOAuthMutation.mutateAsync({providerId: provider.id}) - if (!result.success) { - toast.error(result.error ?? 'Failed to start OAuth') + // No key needed → connect directly → model select + setStep('connecting') + try { + await connectMutation.mutateAsync({providerId: provider.id}) + setIsNewConnection(true) + setStep('model_select') + } catch (error_) { + toast.error(formatError(error_, 'Connection failed')) setStep('select') - return } + }, + [connectByteRover, connectMutation, isAuthorized, onProviderActivated, resetAndClose], + ) - const callbackResult = await awaitOAuthMutation.mutateAsync({ providerId: provider.id }) - if (callbackResult.success) { - setIsNewConnection(true) - setStep('model_select') - } else { - toast.error(callbackResult.error ?? 'OAuth failed') + const handleOAuth = useCallback( + async (provider: ProviderDTO) => { + setStep('connecting') + try { + const result = await startOAuthMutation.mutateAsync({providerId: provider.id}) + if (!result.success) { + toast.error(result.error ?? 'Failed to start OAuth') + setStep('select') + return + } + + const callbackResult = await awaitOAuthMutation.mutateAsync({providerId: provider.id}) + if (callbackResult.success) { + setIsNewConnection(true) + setStep('model_select') + } else { + toast.error(callbackResult.error ?? 'OAuth failed') + setStep('select') + } + } catch (error_) { + toast.error(formatError(error_, 'OAuth failed')) setStep('select') } - } catch (error_) { - toast.error(formatError(error_, 'OAuth failed')) - setStep('select') - } - }, [awaitOAuthMutation, startOAuthMutation]) + }, + [awaitOAuthMutation, startOAuthMutation], + ) const handleAction = useCallback( async (actionId: ProviderActionId) => { if (!selectedProvider) return - switch (actionId) { - case 'activate': { - setStep('connecting') - try { - await setActiveMutation.mutateAsync({ providerId: selectedProvider.id }) - toast.success(`Activated ${selectedProvider.name}`) - onProviderActivated?.() - resetAndClose() - } catch (error_) { - setError(formatError(error_, 'Failed')) - setStep('provider_actions') - } + switch (actionId) { + case 'activate': { + setStep('connecting') + try { + await setActiveMutation.mutateAsync({providerId: selectedProvider.id}) + toast.success(`Activated ${selectedProvider.name}`) + onProviderActivated?.() + resetAndClose() + } catch (error_) { + setError(formatError(error_, 'Failed')) + setStep('provider_actions') + } break } @@ -239,18 +262,18 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou break } - case 'disconnect': { - setStep('connecting') - try { - await disconnectMutation.mutateAsync({ providerId: selectedProvider.id }) - toast.success(`Disconnected ${selectedProvider.name}`) - setStep('select') - setSelectedProvider(undefined) - setError(undefined) - } catch (error_) { - setError(formatError(error_, 'Failed')) - setStep('provider_actions') - } + case 'disconnect': { + setStep('connecting') + try { + await disconnectMutation.mutateAsync({providerId: selectedProvider.id}) + toast.success(`Disconnected ${selectedProvider.name}`) + setStep('select') + setSelectedProvider(undefined) + setError(undefined) + } catch (error_) { + setError(formatError(error_, 'Failed')) + setStep('provider_actions') + } break } @@ -283,34 +306,36 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou async (apiKey: string) => { if (!selectedProvider) return - // Validate first (skip for openai-compatible) - if (selectedProvider.id !== 'openai-compatible' && apiKey) { - try { - const result = await validateMutation.mutateAsync({ apiKey, providerId: selectedProvider.id }) - if (!result.isValid) { - setError(result.error ?? 'Invalid API key') + // Validate first (skip for openai-compatible) + if (selectedProvider.id !== 'openai-compatible' && apiKey) { + try { + const result = await validateMutation.mutateAsync({apiKey, providerId: selectedProvider.id}) + if (!result.isValid) { + setError(result.error ?? 'Invalid API key') + return + } + } catch (error_) { + setError(formatError(error_, 'Validation failed')) return } - } catch (error_) { - setError(formatError(error_, 'Validation failed')) - return } - } - setStep('connecting') - try { - await connectMutation.mutateAsync({ - apiKey: apiKey || undefined, - baseUrl: baseUrl ?? undefined, - providerId: selectedProvider.id, - }) - setIsNewConnection(true) - setStep('model_select') - } catch (error_) { - setError(formatError(error_, 'Connection failed')) - setStep('api_key') - } - }, [baseUrl, connectMutation, selectedProvider, validateMutation]) + setStep('connecting') + try { + await connectMutation.mutateAsync({ + apiKey: apiKey || undefined, + baseUrl: baseUrl ?? undefined, + providerId: selectedProvider.id, + }) + setIsNewConnection(true) + setStep('model_select') + } catch (error_) { + setError(formatError(error_, 'Connection failed')) + setStep('api_key') + } + }, + [baseUrl, connectMutation, selectedProvider, validateMutation], + ) const handleModelSelect = useCallback( async (model: ModelDTO) => { @@ -323,18 +348,20 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou providerId: selectedProvider.id, }) - if (isNewConnection) { - toast.success(`Connected to ${selectedProvider.name}`) - onProviderActivated?.() - resetAndClose() - } else { - toast.success(`Model set to ${model.name}`) - setStep('provider_actions') + if (isNewConnection) { + toast.success(`Connected to ${selectedProvider.name}`) + onProviderActivated?.() + resetAndClose() + } else { + toast.success(`Model set to ${model.name}`) + setStep('provider_actions') + } + } catch (error_) { + toast.error(formatError(error_, 'Failed to set model')) } - } catch (error_) { - toast.error(formatError(error_, 'Failed to set model')) - } - }, [isNewConnection, onProviderActivated, resetAndClose, selectedProvider, setActiveModelMutation]) + }, + [isNewConnection, onProviderActivated, resetAndClose, selectedProvider, setActiveModelMutation], + ) const handleApiKeyBack = useCallback(() => { setError(undefined) @@ -415,6 +442,7 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou setStep('select') setSelectedProvider(undefined) }} + popup={oauthPopupRef.current} /> ) : null } @@ -464,7 +492,9 @@ export function ProviderFlowDialog({onOpenChange, onProviderActivated, open, tou {tourStepLabel && } {renderStep()} diff --git a/src/webui/features/provider/components/provider-flow/provider-select-step.tsx b/src/webui/features/provider/components/provider-flow/provider-select-step.tsx index 2e883f9e6..e35b8d817 100644 --- a/src/webui/features/provider/components/provider-flow/provider-select-step.tsx +++ b/src/webui/features/provider/components/provider-flow/provider-select-step.tsx @@ -1,3 +1,4 @@ +import {Badge} from '@campfirein/byterover-packages/components/badge' import {DialogDescription, DialogHeader, DialogTitle} from '@campfirein/byterover-packages/components/dialog' import {Input} from '@campfirein/byterover-packages/components/input' import {cn} from '@campfirein/byterover-packages/lib/utils' @@ -8,18 +9,31 @@ import type {ProviderDTO} from '../../../../../shared/transport/events' import {providerIcons} from './provider-icons' +const BYTEROVER_PROVIDER_ID = 'byterover' + interface ProviderSelectStepProps { onSelect: (provider: ProviderDTO) => void providers: ProviderDTO[] } +/** + * Sort ByteRover to the top so it shows as the default choice. Everything else + * keeps its server-side ordering. + */ +function orderProviders(providers: ProviderDTO[]): ProviderDTO[] { + const byterover = providers.find((p) => p.id === BYTEROVER_PROVIDER_ID) + if (!byterover) return providers + return [byterover, ...providers.filter((p) => p.id !== BYTEROVER_PROVIDER_ID)] +} + export function ProviderSelectStep({onSelect, providers}: ProviderSelectStepProps) { const [search, setSearch] = useState('') const filtered = useMemo(() => { - if (!search) return providers + const ordered = orderProviders(providers) + if (!search) return ordered const q = search.toLowerCase() - return providers.filter((p) => p.name.toLowerCase().includes(q)) + return ordered.filter((p) => p.name.toLowerCase().includes(q)) }, [providers, search]) return ( @@ -41,6 +55,7 @@ export function ProviderSelectStep({onSelect, providers}: ProviderSelectStepProp {filtered.map((provider) => { const icon = providerIcons[provider.id] const isActive = provider.isCurrent + const isByteRover = provider.id === BYTEROVER_PROVIDER_ID return (
-
-
{provider.name}
-
{provider.description}
+
+
+ {provider.name} + {isByteRover && ( + + Native + + )} +
+
{provider.description}
void + openProviderDialog: () => void reset: () => void setActiveProviderId: (providerId: null | string) => void setLoading: (isLoading: boolean) => void @@ -18,6 +21,7 @@ export interface ProviderActions { const initialState: ProviderState = { activeProviderId: null, + isDialogOpen: false, isLoading: false, providers: [], } @@ -25,6 +29,10 @@ const initialState: ProviderState = { export const useProviderStore = create()((set) => ({ ...initialState, + closeProviderDialog: () => set({isDialogOpen: false}), + + openProviderDialog: () => set({isDialogOpen: true}), + reset: () => set(initialState), setActiveProviderId: (activeProviderId) => set({activeProviderId}), diff --git a/src/webui/features/tasks/components/task-composer-bits.tsx b/src/webui/features/tasks/components/task-composer-bits.tsx index a287f725a..c37b7abe4 100644 --- a/src/webui/features/tasks/components/task-composer-bits.tsx +++ b/src/webui/features/tasks/components/task-composer-bits.tsx @@ -25,7 +25,10 @@ export function CurateAttachmentHint() { export function PrefillBadge({label}: {label: string}) { return ( diff --git a/src/webui/features/tasks/components/task-composer-header.tsx b/src/webui/features/tasks/components/task-composer-header.tsx index 8f79ea6c3..1d50163ba 100644 --- a/src/webui/features/tasks/components/task-composer-header.tsx +++ b/src/webui/features/tasks/components/task-composer-header.tsx @@ -1,3 +1,4 @@ +import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' import {cn} from '@campfirein/byterover-packages/lib/utils' import type {ComposerType} from './task-composer-types' @@ -25,7 +26,19 @@ export function ComposerHeader({ New {type} task - {!inTour && } + {/* + During the tour the slider is shown but locked so users still learn + the affordance — it'd otherwise be invisible until they finish the + tour. Tooltip explains the disabled state. + */} + {inTour ? ( + + } /> + Locked during the tour — the next step covers the other mode. + + ) : ( + + )}

{type === 'query' ? 'Searches' : 'Will dispatch to'}{' '} @@ -35,9 +48,20 @@ export function ComposerHeader({ ) } -function TypeSlider({onChange, value}: {onChange: (next: ComposerType) => void; value: ComposerType}) { +function TypeSlider({ + disabled = false, + onChange, + value, +}: { + disabled?: boolean + onChange: (next: ComposerType) => void + value: ComposerType +}) { return ( -

+
void; className={cn( 'relative z-10 px-3 py-1 text-xs font-medium transition-colors', option === value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80', + disabled && 'cursor-not-allowed hover:text-muted-foreground', )} + disabled={disabled} key={option} onClick={() => onChange(option)} type="button" diff --git a/src/webui/features/tasks/components/task-composer-types.ts b/src/webui/features/tasks/components/task-composer-types.ts index 0a1750a09..91b0cd46c 100644 --- a/src/webui/features/tasks/components/task-composer-types.ts +++ b/src/webui/features/tasks/components/task-composer-types.ts @@ -2,8 +2,8 @@ export type ComposerType = 'curate' | 'query' export const PLACEHOLDER: Record = { curate: - 'JWT tokens expire after 24h. Refresh window is 7 days. Rotation happens on every successful refresh — old refresh token is invalidated immediately.', - query: 'What is our auth token expiration policy?', + 'List the most important conventions and patterns used in this codebase — naming, file organization, testing approach, and any rules a new contributor should know before making changes.', + query: 'What conventions should I follow when making changes?', } export const HELP: Record = { diff --git a/src/webui/features/tasks/components/task-detail-header.tsx b/src/webui/features/tasks/components/task-detail-header.tsx index 6c708580d..293118684 100644 --- a/src/webui/features/tasks/components/task-detail-header.tsx +++ b/src/webui/features/tasks/components/task-detail-header.tsx @@ -56,9 +56,9 @@ function CopyableTaskId({taskId}: {taskId: string}) { const copy = async () => { try { await navigator.clipboard.writeText(taskId) - toast.success('Task ID copied', {duration: 2000, position: 'top-center'}) + toast.success('Task ID copied', {duration: 2000}) } catch { - toast.error('Failed to copy task ID', {duration: 3000, position: 'top-center'}) + toast.error('Failed to copy task ID', {duration: 3000}) } } diff --git a/src/webui/features/tasks/components/task-detail-sections.tsx b/src/webui/features/tasks/components/task-detail-sections.tsx index ab66d861e..3bb6d6059 100644 --- a/src/webui/features/tasks/components/task-detail-sections.tsx +++ b/src/webui/features/tasks/components/task-detail-sections.tsx @@ -1,11 +1,16 @@ +import {Button} from '@campfirein/byterover-packages/components/button' import {Card} from '@campfirein/byterover-packages/components/card' import {cn} from '@campfirein/byterover-packages/lib/utils' -import {Folder, Paperclip} from 'lucide-react' +import {Folder, Paperclip, RotateCcw} from 'lucide-react' import type {StoredTask} from '../types/stored-task' import {formatError} from '../../../lib/error-messages' +import {useProviderStore} from '../../provider/stores/provider-store' +import {useComposerRetryStore} from '../stores/composer-retry-store' +import {composerTypeFromTask} from '../utils/composer-type-from-task' import {shortTaskId} from '../utils/format-time' +import {isProviderTaskError} from '../utils/is-provider-task-error' import {isActiveStatus} from '../utils/task-status' import {AttachmentChip} from './attachment-chip' import {MarkdownInline} from './markdown-inline' @@ -75,7 +80,21 @@ export function ResultSection({content}: {content: string}) { ) } -export function ErrorSection({error}: {error: NonNullable}) { +export function ErrorSection({task}: {task: StoredTask}) { + const {error} = task + const openProviderDialog = useProviderStore((s) => s.openProviderDialog) + const requestRetry = useComposerRetryStore((s) => s.requestRetry) + const showProviderCta = isProviderTaskError({ + error, + hadLlmServiceError: Boolean(task.hadLlmServiceError), + }) + + if (!error) return null + + function retry() { + requestRetry({content: task.content, type: composerTypeFromTask(task.type)}) + } + return (
@@ -83,6 +102,18 @@ export function ErrorSection({error}: {error: NonNullable})

{formatError(error)}

{error.code &&

{error.code}

} +
+ {showProviderCta && ( + + )} + + Your prompt is preserved. +
) diff --git a/src/webui/features/tasks/components/task-detail-view.tsx b/src/webui/features/tasks/components/task-detail-view.tsx index 9ed81285e..c6a30cd31 100644 --- a/src/webui/features/tasks/components/task-detail-view.tsx +++ b/src/webui/features/tasks/components/task-detail-view.tsx @@ -62,7 +62,7 @@ export function TaskDetailView({taskId}: TaskDetailViewProps) { {showLive && } {result && } - {error && } + {error && }
diff --git a/src/webui/features/tasks/components/task-list-empty.tsx b/src/webui/features/tasks/components/task-list-empty.tsx index cc55e3116..ec38d2efd 100644 --- a/src/webui/features/tasks/components/task-list-empty.tsx +++ b/src/webui/features/tasks/components/task-list-empty.tsx @@ -7,6 +7,7 @@ import {ListTodo, Plus} from 'lucide-react' import type {StatusFilter} from '../stores/task-store' +import {TourPointer} from '../../onboarding/components/tour-pointer' import {STATUS_LABEL} from './task-list-filter-bar' export function PlaceholderCard({children, withDots}: {children: ReactNode; withDots?: boolean}) { @@ -27,7 +28,7 @@ export function LoadingState() { return
Loading tasks…
} -export function EmptyState({onNewTask}: {onNewTask: () => void}) { +export function EmptyState({onNewTask, tourCue}: {onNewTask: () => void; tourCue?: string}) { return (
@@ -38,10 +39,12 @@ export function EmptyState({onNewTask}: {onNewTask: () => void}) {

- + + + or run from the CLI
diff --git a/src/webui/features/tasks/components/task-list-filter-bar.tsx b/src/webui/features/tasks/components/task-list-filter-bar.tsx index c58ba674d..ab9af5fec 100644 --- a/src/webui/features/tasks/components/task-list-filter-bar.tsx +++ b/src/webui/features/tasks/components/task-list-filter-bar.tsx @@ -3,6 +3,7 @@ import {Input} from '@campfirein/byterover-packages/components/input' import {cn} from '@campfirein/byterover-packages/lib/utils' import {Plus, Search} from 'lucide-react' +import {TourPointer} from '../../onboarding/components/tour-pointer' import {STATUS_FILTERS, type StatusFilter, type useStatusBreakdown} from '../stores/task-store' export const STATUS_LABEL: Record = { @@ -27,6 +28,7 @@ export function FilterBar({ onStatusChange, searchQuery, statusFilter, + tourCue, }: { breakdown: ReturnType onNewTask: () => void @@ -34,6 +36,7 @@ export function FilterBar({ onStatusChange: (filter: StatusFilter) => void searchQuery: string statusFilter: StatusFilter + tourCue?: string }) { return (
@@ -73,10 +76,12 @@ export function FilterBar({ />
- + + +
) diff --git a/src/webui/features/tasks/components/task-list-view.tsx b/src/webui/features/tasks/components/task-list-view.tsx index ac4d56105..11f13a3fe 100644 --- a/src/webui/features/tasks/components/task-list-view.tsx +++ b/src/webui/features/tasks/components/task-list-view.tsx @@ -1,11 +1,16 @@ import {Button} from '@campfirein/byterover-packages/components/button' import {Sheet, SheetContent} from '@campfirein/byterover-packages/components/sheet' -import {useMemo, useState} from 'react' +import {useEffect, useMemo, useState} from 'react' import {useSearchParams} from 'react-router-dom' +import type {ComposerType} from './task-composer-types' + import {useTransportStore} from '../../../stores/transport-store' +import {CURATE_EXAMPLE, QUERY_EXAMPLE, TOUR_STEP_LABEL} from '../../onboarding/lib/tour-examples' +import {useOnboardingStore} from '../../onboarding/stores/onboarding-store' import {useGetTasks} from '../api/get-tasks' import {useTickingNow} from '../hooks/use-ticking-now' +import {useComposerRetryStore} from '../stores/composer-retry-store' import {statusMatchesFilter, taskMatchesQuery, useStatusBreakdown, useTaskStore} from '../stores/task-store' import {isTerminalStatus} from '../utils/task-status' import {TaskComposerSheet} from './task-composer' @@ -49,12 +54,51 @@ export function TaskListView() { const now = useTickingNow(breakdown.running > 0) const [selectedIds, setSelectedIds] = useState>(new Set()) - const [composer, setComposer] = useState<{open: boolean}>({open: false}) + const [composer, setComposer] = useState<{ + initialContent?: string + initialType?: ComposerType + open: boolean + }>({open: false}) + + const tourActive = useOnboardingStore((s) => s.tourActive) + const tourStep = useOnboardingStore((s) => s.tourStep) + const tourTaskId = useOnboardingStore((s) => s.tourTaskId) + const setTourTaskId = useOnboardingStore((s) => s.setTourTaskId) + const inComposerStep = tourStep === 'curate' || tourStep === 'query' + const inTour = tourActive && inComposerStep + const tourCueLabel = + inTour && !tourTaskId + ? tourStep === 'curate' + ? 'Click to capture knowledge' + : 'Click to ask a question' + : undefined + + const openComposer = () => { + if (inTour) { + const example = tourStep === 'curate' ? CURATE_EXAMPLE : QUERY_EXAMPLE + setComposer({initialContent: example, initialType: tourStep, open: true}) + return + } + + setComposer({open: true}) + } - const openComposer = () => setComposer({open: true}) const closeComposer = () => setComposer({open: false}) + // Pick up retry seeds from the task-detail "Try again" CTA. Both normal and + // tour mode use this composer now, so the seed flow is shared. + const retrySeed = useComposerRetryStore((s) => s.seed) + const consumeRetry = useComposerRetryStore((s) => s.consume) + + useEffect(() => { + if (!retrySeed) return + setComposer({initialContent: retrySeed.content, initialType: retrySeed.type, open: true}) + closeTask() + consumeRetry() + }, [retrySeed, consumeRetry, closeTask]) + const onComposerSubmitted = (taskId: string, openDetail: boolean) => { + if (inTour) setTourTaskId(taskId) if (openDetail) openTask(taskId) } @@ -139,6 +183,9 @@ export function TaskListView() { }} searchQuery={searchQuery} statusFilter={statusFilter} + // Coachmark moves between the empty-state CTA and the header CTA so + // we never highlight both simultaneously. + tourCue={tourCueLabel && tasks.length > 0 ? tourCueLabel : undefined} /> )} @@ -148,7 +195,7 @@ export function TaskListView() { ) : tasks.length === 0 ? ( - + ) : ( - +
) } diff --git a/src/webui/features/tasks/hooks/use-composer-submit.ts b/src/webui/features/tasks/hooks/use-composer-submit.ts index 20151777a..4958174c5 100644 --- a/src/webui/features/tasks/hooks/use-composer-submit.ts +++ b/src/webui/features/tasks/hooks/use-composer-submit.ts @@ -42,13 +42,11 @@ export function useComposerSubmit(args: { try { await createMutation.mutateAsync(payload) const verb = args.type === 'query' ? 'Query' : 'Curate' - toast.success(`${verb} task queued`, {position: 'top-center'}) + toast.success(`${verb} task queued`) args.onSubmitted?.(taskId, args.openDetailAfter) args.onClose() } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to create task', { - position: 'top-center', - }) + toast.error(error instanceof Error ? error.message : 'Failed to create task') } } diff --git a/src/webui/features/tasks/hooks/use-task-subscriptions.ts b/src/webui/features/tasks/hooks/use-task-subscriptions.ts index 5bb66dd2c..b2252d744 100644 --- a/src/webui/features/tasks/hooks/use-task-subscriptions.ts +++ b/src/webui/features/tasks/hooks/use-task-subscriptions.ts @@ -79,6 +79,13 @@ interface LlmThinkingPayload { taskId?: string } +interface LlmErrorPayload { + code?: string + error: string + sessionId?: string + taskId?: string +} + export function useTaskSubscriptions(): void { const apiClient = useTransportStore((s) => s.apiClient) @@ -175,6 +182,11 @@ export function useTaskSubscriptions(): void { timestamp: Date.now(), }) }), + + apiClient.on(LlmEvents.ERROR, (data) => { + if (!data.taskId) return + store.markLlmServiceError(data.taskId) + }), ) return () => { diff --git a/src/webui/features/tasks/stores/composer-retry-store.ts b/src/webui/features/tasks/stores/composer-retry-store.ts new file mode 100644 index 000000000..c2d40be86 --- /dev/null +++ b/src/webui/features/tasks/stores/composer-retry-store.ts @@ -0,0 +1,33 @@ +import {create} from 'zustand' + +import type {ComposerType} from '../components/task-composer-types' + +/** + * Hand-off slot between the task-detail "Try again" CTA and the composer + * host. ErrorSection writes a seed; whichever composer host is active + * (TaskListView in normal mode, TourHost in tour mode) reads + clears it + * and re-opens the composer pre-filled with the failed task's content so + * the user doesn't have to retype. + */ +export interface ComposerRetrySeed { + content: string + type: ComposerType +} + +interface ComposerRetryState { + consume: () => ComposerRetrySeed | null + requestRetry: (seed: ComposerRetrySeed) => void + seed: ComposerRetrySeed | null +} + +export const useComposerRetryStore = create((set, get) => ({ + consume() { + const {seed} = get() + if (seed) set({seed: null}) + return seed + }, + + requestRetry: (seed) => set({seed}), + + seed: null, +})) diff --git a/src/webui/features/tasks/stores/task-store.ts b/src/webui/features/tasks/stores/task-store.ts index 4b557a215..3cbc56944 100644 --- a/src/webui/features/tasks/stores/task-store.ts +++ b/src/webui/features/tasks/stores/task-store.ts @@ -57,6 +57,7 @@ interface TaskActions { type: 'reasoning' | 'text' }) => void clearCompleted: () => void + markLlmServiceError: (taskId: string) => void mergeTasks: (incoming: TaskListItem[]) => void removeTask: (taskId: string) => void reset: () => void @@ -110,6 +111,9 @@ export const useTaskStore = create()( clearCompleted: () => set((state) => ({tasks: state.tasks.filter((task) => !isTerminalStatus(task.status))})), + markLlmServiceError: (taskId) => + set((state) => applyToTask(state, taskId, (task) => ({...task, hadLlmServiceError: true})) ?? {}), + mergeTasks: (incoming) => set((state) => ({tasks: mergeTaskList(state.tasks, incoming)})), removeTask: (taskId) => set((state) => ({tasks: removeTaskFromList(state.tasks, taskId)})), diff --git a/src/webui/features/tasks/types/stored-task.ts b/src/webui/features/tasks/types/stored-task.ts index 36fefabed..7e9c03882 100644 --- a/src/webui/features/tasks/types/stored-task.ts +++ b/src/webui/features/tasks/types/stored-task.ts @@ -27,6 +27,12 @@ export interface ReasoningContentItem { } export interface StoredTask extends TaskListItem { + /** + * True if we received any `llmservice:error` broadcast for this task. + * Used to show a provider-config CTA on the error surface even when the + * task:error payload doesn't carry a structured error code. + */ + hadLlmServiceError?: boolean isStreaming?: boolean reasoningContents?: ReasoningContentItem[] /** Set when the agent's response stream resolves (final assistant message). */ diff --git a/src/webui/features/tasks/utils/composer-type-from-task.ts b/src/webui/features/tasks/utils/composer-type-from-task.ts new file mode 100644 index 000000000..5f6641794 --- /dev/null +++ b/src/webui/features/tasks/utils/composer-type-from-task.ts @@ -0,0 +1,11 @@ +import type {ComposerType} from '../components/task-composer-types' + +/** + * Map a stored task type to the composer's two-way switch. The composer only + * knows about `curate` and `query` — server-side `curate-folder` and `search` + * collapse onto those for the purposes of refilling the form. + */ +export function composerTypeFromTask(taskType: string): ComposerType { + if (taskType === 'query' || taskType === 'search') return 'query' + return 'curate' +} diff --git a/src/webui/features/tasks/utils/is-provider-task-error.ts b/src/webui/features/tasks/utils/is-provider-task-error.ts new file mode 100644 index 000000000..2849a46ef --- /dev/null +++ b/src/webui/features/tasks/utils/is-provider-task-error.ts @@ -0,0 +1,38 @@ +type TaskError = { + code?: string + message: string + name?: string +} + +type Input = { + error: TaskError | undefined + /** True if an `llmservice:error` broadcast landed for this task (tracked in the task store). */ + hadLlmServiceError: boolean +} + +/** + * Task error codes the daemon emits directly for provider-config issues. + */ +const PROVIDER_CODES = new Set([ + 'ERR_LLM_ERROR', + 'ERR_LLM_RATE_LIMIT', + 'ERR_OAUTH_REFRESH_FAILED', + 'ERR_OAUTH_TOKEN_EXPIRED', + 'ERR_PROVIDER_NOT_CONFIGURED', +]) + +/** + * A task error is provider-class when either: + * a) the daemon gave us a provider-class error code, or + * b) we observed an `llmservice:error` broadcast for this task. + * + * The `llmservice:error` fallback exists because the daemon doesn't always + * propagate the structured code through `task:error` — `CipherAgent.run()` + * unwraps the fatal LlmError into a bare `new Error(message)` before the + * TaskError serializer runs. + */ +export function isProviderTaskError({error, hadLlmServiceError}: Input): boolean { + if (hadLlmServiceError) return true + if (error?.code && PROVIDER_CODES.has(error.code)) return true + return false +} diff --git a/src/webui/features/vc/api/execute-vc-init.ts b/src/webui/features/vc/api/execute-vc-init.ts index 2af39035f..557855756 100644 --- a/src/webui/features/vc/api/execute-vc-init.ts +++ b/src/webui/features/vc/api/execute-vc-init.ts @@ -5,6 +5,7 @@ import type {MutationConfig} from '../../../lib/react-query' import {type IVcInitResponse, VcEvents} from '../../../../shared/transport/events/vc-events' import {useTransportStore} from '../../../stores/transport-store' import {getVcBranchesQueryOptions} from './get-vc-branches' +import {getVcRemoteQueryOptions} from './get-vc-remote' import {getVcStatusQueryOptions} from './get-vc-status' export const executeVcInit = (): Promise => { @@ -26,6 +27,7 @@ export const useVcInit = ({mutationConfig}: UseVcInitOptions = {}) => { onSuccess(...args) { queryClient.invalidateQueries({queryKey: getVcStatusQueryOptions().queryKey}) queryClient.invalidateQueries({queryKey: getVcBranchesQueryOptions().queryKey}) + queryClient.invalidateQueries({queryKey: getVcRemoteQueryOptions().queryKey}) onSuccess?.(...args) }, ...rest, diff --git a/src/webui/features/vc/api/get-vc-config.ts b/src/webui/features/vc/api/get-vc-config.ts new file mode 100644 index 000000000..6c6cc28b4 --- /dev/null +++ b/src/webui/features/vc/api/get-vc-config.ts @@ -0,0 +1,52 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' + +import type {QueryConfig} from '../../../lib/react-query' + +import { + type IVcConfigRequest, + type IVcConfigResponse, + type VcConfigKey, + VcErrorCode, + VcEvents, +} from '../../../../shared/transport/events/vc-events' +import {hasCode} from '../../../lib/transport-error' +import {useTransportStore} from '../../../stores/transport-store' + +export type VcConfigValues = { + email: string | undefined + name: string | undefined +} + +async function readKey(key: VcConfigKey): Promise { + const {apiClient} = useTransportStore.getState() + if (!apiClient) throw new Error('Not connected') + try { + const response = await apiClient.request(VcEvents.CONFIG, {key}) + return response.value + } catch (error) { + if (hasCode(error) && error.code === VcErrorCode.CONFIG_KEY_NOT_SET) return undefined + throw error + } +} + +export const getVcConfig = async (): Promise => { + const [name, email] = await Promise.all([readKey('user.name'), readKey('user.email')]) + return {email, name} +} + +export const getVcConfigQueryOptions = () => + queryOptions({ + queryFn: getVcConfig, + queryKey: ['vc', 'config'], + staleTime: 5000, + }) + +type UseGetVcConfigOptions = { + queryConfig?: QueryConfig +} + +export const useGetVcConfig = ({queryConfig}: UseGetVcConfigOptions = {}) => + useQuery({ + ...getVcConfigQueryOptions(), + ...queryConfig, + }) diff --git a/src/webui/features/vc/api/get-vc-remote.ts b/src/webui/features/vc/api/get-vc-remote.ts new file mode 100644 index 000000000..d59fc7172 --- /dev/null +++ b/src/webui/features/vc/api/get-vc-remote.ts @@ -0,0 +1,52 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' + +import type {QueryConfig} from '../../../lib/react-query' + +import { + type IVcRemoteRequest, + type IVcRemoteResponse, + VcErrorCode, + VcEvents, +} from '../../../../shared/transport/events/vc-events' +import {hasCode} from '../../../lib/transport-error' +import {useTransportStore} from '../../../stores/transport-store' + +export type VcRemoteShow = { + gitInitialized: boolean + url: string | undefined +} + +export const getVcRemote = async (): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) throw new Error('Not connected') + + try { + const response = await apiClient.request(VcEvents.REMOTE, { + subcommand: 'show', + }) + return {gitInitialized: true, url: response.url} + } catch (error) { + if (hasCode(error) && error.code === VcErrorCode.GIT_NOT_INITIALIZED) { + return {gitInitialized: false, url: undefined} + } + + throw error + } +} + +export const getVcRemoteQueryOptions = () => + queryOptions({ + queryFn: getVcRemote, + queryKey: ['vc', 'remote'], + staleTime: 5000, + }) + +type UseGetVcRemoteOptions = { + queryConfig?: QueryConfig +} + +export const useGetVcRemote = ({queryConfig}: UseGetVcRemoteOptions = {}) => + useQuery({ + ...getVcRemoteQueryOptions(), + ...queryConfig, + }) diff --git a/src/webui/features/vc/api/set-vc-config.ts b/src/webui/features/vc/api/set-vc-config.ts new file mode 100644 index 000000000..10d943518 --- /dev/null +++ b/src/webui/features/vc/api/set-vc-config.ts @@ -0,0 +1,35 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import type {MutationConfig} from '../../../lib/react-query' + +import { + type IVcConfigRequest, + type IVcConfigResponse, + VcEvents, +} from '../../../../shared/transport/events/vc-events' +import {useTransportStore} from '../../../stores/transport-store' +import {getVcConfigQueryOptions} from './get-vc-config' + +export const setVcConfig = (request: IVcConfigRequest): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + return apiClient.request(VcEvents.CONFIG, request) +} + +type UseSetVcConfigOptions = { + mutationConfig?: MutationConfig +} + +export const useSetVcConfig = ({mutationConfig}: UseSetVcConfigOptions = {}) => { + const queryClient = useQueryClient() + const {onSuccess, ...rest} = mutationConfig ?? {} + + return useMutation({ + onSuccess(...args) { + queryClient.invalidateQueries({queryKey: getVcConfigQueryOptions().queryKey}) + onSuccess?.(...args) + }, + ...rest, + mutationFn: setVcConfig, + }) +} diff --git a/src/webui/features/vc/api/set-vc-remote.ts b/src/webui/features/vc/api/set-vc-remote.ts new file mode 100644 index 000000000..9a6a05e98 --- /dev/null +++ b/src/webui/features/vc/api/set-vc-remote.ts @@ -0,0 +1,41 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import type {MutationConfig} from '../../../lib/react-query' + +import { + type IVcRemoteRequest, + type IVcRemoteResponse, + VcEvents, +} from '../../../../shared/transport/events/vc-events' +import {useTransportStore} from '../../../stores/transport-store' +import {getVcRemoteQueryOptions} from './get-vc-remote' + +export type SetVcRemoteInput = + | {subcommand: 'add' | 'set-url'; url: string} + | {subcommand: 'remove'} + +export const setVcRemote = (input: SetVcRemoteInput): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + const request: IVcRemoteRequest = + input.subcommand === 'remove' ? {subcommand: 'remove'} : {subcommand: input.subcommand, url: input.url} + return apiClient.request(VcEvents.REMOTE, request) +} + +type UseSetVcRemoteOptions = { + mutationConfig?: MutationConfig +} + +export const useSetVcRemote = ({mutationConfig}: UseSetVcRemoteOptions = {}) => { + const queryClient = useQueryClient() + const {onSuccess, ...rest} = mutationConfig ?? {} + + return useMutation({ + onSuccess(...args) { + queryClient.invalidateQueries({queryKey: getVcRemoteQueryOptions().queryKey}) + onSuccess?.(...args) + }, + ...rest, + mutationFn: setVcRemote, + }) +} diff --git a/src/webui/features/vc/components/branch-dropdown.tsx b/src/webui/features/vc/components/branch-dropdown.tsx index 518bacfb0..1b998b595 100644 --- a/src/webui/features/vc/components/branch-dropdown.tsx +++ b/src/webui/features/vc/components/branch-dropdown.tsx @@ -37,8 +37,6 @@ type DialogKind = 'new-branch' | null type DeleteTarget = {branchName: string} -const TOAST_OPTS = {position: 'top-center'} as const - function triggerLabel(status: ReturnType['data']): string { if (!status) return 'branch' if (!status.initialized) return 'No git repo' @@ -113,10 +111,9 @@ export function BranchDropdown() { setOpen(false) try { await checkout.mutateAsync({branch: branchName}) - toast.success(message, TOAST_OPTS) + toast.success(message) } catch (error) { toast.error('Failed to switch branch', { - ...TOAST_OPTS, description: formatError(error), }) } @@ -147,10 +144,9 @@ export function BranchDropdown() { // Tracking will fall back to unset; the user can retry via CLI. } - toast.success(`Switched to ${localName} (tracking ${branch.name})`, TOAST_OPTS) + toast.success(`Switched to ${localName} (tracking ${branch.name})`) } catch (error) { toast.error('Failed to checkout remote branch', { - ...TOAST_OPTS, description: formatError(error), }) } @@ -168,7 +164,6 @@ export function BranchDropdown() { function handleFetchAll() { setOpen(false) toast.promise(fetchMut.mutateAsync({}), { - ...TOAST_OPTS, error: (err: unknown) => ({ description: formatError(err), message: 'Fetch failed', @@ -181,7 +176,6 @@ export function BranchDropdown() { function handlePull() { setOpen(false) toast.promise(pull.mutateAsync({}), { - ...TOAST_OPTS, error: (err: unknown) => ({ description: formatError(err), message: 'Pull failed', diff --git a/src/webui/features/vc/components/callout-row.tsx b/src/webui/features/vc/components/callout-row.tsx new file mode 100644 index 000000000..eb8f995b3 --- /dev/null +++ b/src/webui/features/vc/components/callout-row.tsx @@ -0,0 +1,19 @@ +import type {ReactNode} from 'react' + +type Props = { + action: ReactNode + description: ReactNode + title: ReactNode +} + +export function CalloutRow({action, description, title}: Props) { + return ( +
+
+ {title} + {description} +
+ {action} +
+ ) +} diff --git a/src/webui/features/vc/components/changes-panel.tsx b/src/webui/features/vc/components/changes-panel.tsx index 00d5f9c69..a52a55a00 100644 --- a/src/webui/features/vc/components/changes-panel.tsx +++ b/src/webui/features/vc/components/changes-panel.tsx @@ -8,6 +8,7 @@ import type { ChangeFile } from '../types' import successTick from '../../../assets/success-tick.svg' import { formatError } from '../../../lib/error-messages' +import { toastVcError } from '../../../lib/toast-vc-error' import { useTransportStore } from '../../../stores/transport-store' import { useAuthStore } from '../../auth/stores/auth-store' import { useVcAdd } from '../api/execute-vc-add' @@ -128,7 +129,7 @@ export function ChangesPanel() { toast.success('Committed') return true } catch (error) { - toast.error(formatError(error, 'Failed to commit', {projectPath: selectedProject})) + toastVcError(error, 'Failed to commit', navigate, {projectPath: selectedProject}) return false } } @@ -139,7 +140,7 @@ export function ChangesPanel() { toast.success('Merge committed') return true } catch (error) { - toast.error(formatError(error, 'Failed to commit merge', {projectPath: selectedProject})) + toastVcError(error, 'Failed to commit merge', navigate, {projectPath: selectedProject}) return false } } @@ -176,7 +177,7 @@ export function ChangesPanel() { setShowStageAllConfirm(false) } } catch (error) { - toast.error(formatError(error, 'Failed to stage & commit', {projectPath: selectedProject})) + toastVcError(error, 'Failed to stage & commit', navigate, {projectPath: selectedProject}) } } @@ -185,7 +186,7 @@ export function ChangesPanel() { const result = await pushMutation.mutateAsync({ setUpstream: !status.trackingBranch }) toast.success(result.alreadyUpToDate ? 'Already up to date' : `Pushed ${result.branch}`) } catch (error) { - toast.error(formatError(error, 'Failed to push')) + toastVcError(error, 'Failed to push', navigate) } } @@ -198,7 +199,7 @@ export function ChangesPanel() { toast.success(result.alreadyUpToDate ? 'Already up to date' : `Pulled ${result.branch}`) } } catch (error) { - toast.error(formatError(error, 'Failed to pull')) + toastVcError(error, 'Failed to pull', navigate) } } diff --git a/src/webui/features/vc/components/delete-branch-dialog.tsx b/src/webui/features/vc/components/delete-branch-dialog.tsx index a40cf0650..530d9e8db 100644 --- a/src/webui/features/vc/components/delete-branch-dialog.tsx +++ b/src/webui/features/vc/components/delete-branch-dialog.tsx @@ -25,12 +25,10 @@ export function DeleteBranchDialog({branchName, onOpenChange, open}: DeleteBranc try { await del.mutateAsync(branchName) - toast.success(`Deleted ${branchName}`, {position: 'top-center'}) + toast.success(`Deleted ${branchName}`) onOpenChange(false) } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete branch', { - position: 'top-center', - }) + toast.error(error instanceof Error ? error.message : 'Failed to delete branch') } } diff --git a/src/webui/features/vc/components/identity-panel.tsx b/src/webui/features/vc/components/identity-panel.tsx new file mode 100644 index 000000000..b51f4cff0 --- /dev/null +++ b/src/webui/features/vc/components/identity-panel.tsx @@ -0,0 +1,252 @@ +import {Avatar, AvatarFallback, AvatarImage} from '@campfirein/byterover-packages/components/avatar' +import {Button} from '@campfirein/byterover-packages/components/button' +import {Field, FieldError, FieldLabel} from '@campfirein/byterover-packages/components/field' +import {Input} from '@campfirein/byterover-packages/components/input' +import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' +import {LoaderCircle} from 'lucide-react' +import {type FormEvent, useId, useState} from 'react' +import {toast} from 'sonner' + +import type {UserDTO} from '../../../../shared/transport/types/dto' + +import {formatError} from '../../../lib/error-messages' +import {noop} from '../../../lib/noop' +import {initials} from '../../../utils/initials' +import {useAuthStore} from '../../auth/stores/auth-store' +import {useGetVcConfig} from '../api/get-vc-config' +import {useSetVcConfig} from '../api/set-vc-config' +import {isValidEmail} from '../utils/is-valid-email' +import {SettingsSection} from './settings-section' + +type IdentityValues = { + email: string + name: string +} + +/** + * Empty-state row: lead with the suggested account identity instead of a + * foreground "Not set" alert. When no account is connected, fall back to a + * minimal "Set manually" affordance. + */ +function NotSetRow({ + isPending, + onApplyAccount, + onSetManually, + user, +}: { + isPending: boolean + onApplyAccount: () => void + onSetManually: () => void + user: null | UserDTO +}) { + const containerClass = + 'flex items-center justify-between gap-3 rounded-md border border-dashed border-border bg-muted/40 px-3.5 py-2.5' + + if (!user) { + return ( +
+
+ Identity not configured + + Optional — used for commit attribution. + +
+ +
+ ) + } + + const displayName = user.name ?? user.email + return ( +
+
+ + + {initials(displayName)} + +
+ + {displayName} <{user.email}> + + + From your ByteRover account — optional, used for commit attribution. + +
+
+
+ + +
+
+ ) +} + +function CompactRow({email, name, onEdit}: {email: string; name: string; onEdit: () => void}) { + return ( +
+ + {name} <{email}> + + +
+ ) +} + +type EditFormProps = { + initial: IdentityValues + isPending: boolean + onCancel: () => void + onSubmit: (values: IdentityValues) => Promise +} + +function EditForm({initial, isPending, onCancel, onSubmit}: EditFormProps) { + const nameId = useId() + const emailId = useId() + const [name, setName] = useState(initial.name) + const [email, setEmail] = useState(initial.email) + + const trimmedName = name.trim() + const trimmedEmail = email.trim() + const dirty = trimmedName !== initial.name || trimmedEmail !== initial.email + const complete = trimmedName.length > 0 && trimmedEmail.length > 0 + const emailInvalid = trimmedEmail.length > 0 && !isValidEmail(trimmedEmail) + // Only surface the error once the user has interacted (dirty) — don't shout + // on an initially-empty form. + const showEmailError = emailInvalid && dirty + const canSubmit = dirty && complete && !emailInvalid && !isPending + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (!canSubmit) return + await onSubmit({email: trimmedEmail, name: trimmedName}) + } + + function fireSubmit(event: FormEvent) { + handleSubmit(event).catch(noop) + } + + return ( +
+ + Name + setName(e.target.value)} + placeholder="Your name" + value={name} + /> + + + + Email + setEmail(e.target.value)} + placeholder="you@example.com" + type="email" + value={email} + /> + {showEmailError && Enter a valid email address.} + + +
+ + +
+
+ ) +} + +export function IdentityPanel() { + const {data: config, error, isError, isLoading, refetch} = useGetVcConfig() + const setConfig = useSetVcConfig() + const user = useAuthStore((s) => s.user) + const [editing, setEditing] = useState(false) + + const storedName = config?.name ?? '' + const storedEmail = config?.email ?? '' + const configured = Boolean(storedName && storedEmail) + + async function save(values: IdentityValues) { + try { + // Serial — the daemon's config write is read-merge-write per field, + // so concurrent writes race and clobber each other. + if (values.name && values.name !== storedName) { + await setConfig.mutateAsync({key: 'user.name', value: values.name}) + } + + if (values.email && values.email !== storedEmail) { + await setConfig.mutateAsync({key: 'user.email', value: values.email}) + } + + toast.success('Git identity saved.') + setEditing(false) + } catch (error_) { + toast.error(formatError(error_, 'Failed to save identity.')) + throw error_ + } + } + + async function applyFromAccount() { + if (!user) return + try { + await setConfig.mutateAsync({key: 'user.name', value: user.name ?? user.email}) + await setConfig.mutateAsync({key: 'user.email', value: user.email}) + toast.success('Git identity applied from your ByteRover account.') + } catch (error_) { + toast.error(formatError(error_, 'Failed to apply identity.')) + } + } + + return ( + : undefined} + compact={!editing} + description="Recorded on every commit in this project." + error={isError ? error : undefined} + errorFallback="Failed to load identity" + onRetry={() => refetch().catch(noop)} + title="Git identity" + > + {config ? ( + editing ? ( + setEditing(false)} + onSubmit={save} + /> + ) : configured ? ( + setEditing(true)} /> + ) : ( + applyFromAccount().catch(noop)} + onSetManually={() => setEditing(true)} + user={user} + /> + ) + ) : ( +
+ + +
+ )} +
+ ) +} diff --git a/src/webui/features/vc/components/initialize-vc-button.tsx b/src/webui/features/vc/components/initialize-vc-button.tsx index 0ecc71402..0c510869d 100644 --- a/src/webui/features/vc/components/initialize-vc-button.tsx +++ b/src/webui/features/vc/components/initialize-vc-button.tsx @@ -10,14 +10,9 @@ export function InitializeVcButton() { async function handleInit() { try { const result = await init.mutateAsync() - toast.success( - result.reinitialized ? 'Reinitialized version control' : 'Initialized version control', - {position: 'top-center'}, - ) + toast.success(result.reinitialized ? 'Reinitialized version control' : 'Initialized version control') } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to initialize', { - position: 'top-center', - }) + toast.error(error instanceof Error ? error.message : 'Failed to initialize') } } diff --git a/src/webui/features/vc/components/new-branch-dialog.tsx b/src/webui/features/vc/components/new-branch-dialog.tsx index 34dcaadbc..2e3c0e505 100644 --- a/src/webui/features/vc/components/new-branch-dialog.tsx +++ b/src/webui/features/vc/components/new-branch-dialog.tsx @@ -45,7 +45,6 @@ export function NewBranchDialog({initialName = '', onOpenChange, open, startPoin await checkout.mutateAsync({branch: trimmed, create: true, startPoint}) toast.success( startPoint ? `Created ${trimmed} from ${startPoint} and switched to it` : `Created and switched to ${trimmed}`, - {position: 'top-center'}, ) onOpenChange(false) } catch (error) { diff --git a/src/webui/features/vc/components/remotes-panel.tsx b/src/webui/features/vc/components/remotes-panel.tsx new file mode 100644 index 000000000..4911b6bae --- /dev/null +++ b/src/webui/features/vc/components/remotes-panel.tsx @@ -0,0 +1,315 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@campfirein/byterover-packages/components/alert-dialog' +import {Button} from '@campfirein/byterover-packages/components/button' +import {Field, FieldDescription, FieldError, FieldLabel} from '@campfirein/byterover-packages/components/field' +import {Input} from '@campfirein/byterover-packages/components/input' +import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' +import {ExternalLink} from 'lucide-react' +import {type ComponentRef, type FormEvent, type KeyboardEvent, useEffect, useId, useRef, useState} from 'react' +import {toast} from 'sonner' + +import {formatError} from '../../../lib/error-messages' +import {noop} from '../../../lib/noop' +import {useGetEnvironmentConfig} from '../../config/api/get-environment-config' +import {useGetVcRemote} from '../api/get-vc-remote' +import {useSetVcRemote} from '../api/set-vc-remote' +import {detectGitUrlType} from '../utils/detect-git-url-type' +import {validateRemoteUrl} from '../utils/validate-remote-url' +import {CalloutRow} from './callout-row' +import {InitializeVcButton} from './initialize-vc-button' +import {SettingsSection} from './settings-section' + +const ORIGIN_NAME = 'origin' + +function RemoteRow({ + isDeleting, + onDelete, + onEdit, + url, +}: { + isDeleting: boolean + onDelete: () => void + onEdit: () => void + url: string +}) { + const urlType = detectGitUrlType(url) + const isReadOnly = urlType === 'ssh' || urlType === 'git' + return ( +
+
+ {ORIGIN_NAME} + {url} +
+ + +
+
+ {isReadOnly && ( +

+ Read-only from the web UI. Push and pull require an SSH agent; change to HTTPS to use them here. +

+ )} +
+ ) +} + +type DeleteRemoteDialogProps = { + isPending: boolean + onConfirm: () => Promise + onOpenChange: (open: boolean) => void + open: boolean + url: string +} + +function DeleteRemoteDialog({isPending, onConfirm, onOpenChange, open, url}: DeleteRemoteDialogProps) { + function fire() { + onConfirm().catch(noop) + } + + return ( + + + + + Remove {ORIGIN_NAME}? + + + Removes {url} from this + project. Push, pull, and fetch will be disabled until you set a new remote. + + + + + Cancel + + + + + ) +} + +type EditFormProps = { + initial: string + isPending: boolean + mode: 'add' | 'edit' + onCancel: () => void + onSubmit: (url: string) => Promise + placeholder: string + webAppUrl?: string +} + +function EditForm({initial, isPending, mode, onCancel, onSubmit, placeholder, webAppUrl}: EditFormProps) { + const urlId = useId() + const [value, setValue] = useState(initial) + const [error, setError] = useState() + const inputRef = useRef>(null) + const dirty = value.trim() !== initial + const validationError = validateRemoteUrl(value) + const canSubmit = dirty && !validationError && !isPending + + useEffect(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, []) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + if (validationError) { + setError(validationError) + return + } + + try { + await onSubmit(value.trim()) + setError(undefined) + } catch (error_) { + setError(formatError(error_, 'Failed to save remote')) + } + } + + function fireSubmit(event: FormEvent) { + handleSubmit(event).catch(noop) + } + + function handleKey(event: KeyboardEvent>) { + if (event.key === 'Escape') { + event.preventDefault() + onCancel() + } + } + + const showValidationError = error ?? (dirty ? validationError : undefined) + const showReplacePreview = mode === 'edit' && !showValidationError && initial !== '' + + return ( +
+ +
+ + {mode === 'add' ? 'Adding ' : 'Editing '} + {ORIGIN_NAME} + + {mode === 'add' && webAppUrl && ( + + )} +
+ { + setValue(e.target.value) + if (error) setError(undefined) + }} + onKeyDown={handleKey} + placeholder={placeholder} + ref={inputRef} + value={value} + /> + {showValidationError && {showValidationError}} + {!showValidationError && mode === 'add' && ( + Only HTTPS URLs are supported right now. + )} + {showReplacePreview && ( + + Replaces current URL {initial} + + )} +
+ +
+ + +
+
+ ) +} + +export function RemotesPanel() { + const {data, error, isError, refetch} = useGetVcRemote() + const {data: envConfig} = useGetEnvironmentConfig() + const setRemote = useSetVcRemote() + const [editing, setEditing] = useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + + const url = data?.url + const gitInitialized = data?.gitInitialized + const hasRemote = Boolean(url) + const showAddAction = gitInitialized === true && !hasRemote && !editing + const urlPlaceholder = `${envConfig?.gitRemoteBaseUrl ?? 'https://example.com'}/team/space.git` + + async function submit(next: string) { + await setRemote.mutateAsync({subcommand: hasRemote ? 'set-url' : 'add', url: next}) + toast.success(hasRemote ? 'Origin replaced.' : 'Remote added.') + setEditing(false) + } + + async function deleteRemote() { + try { + await setRemote.mutateAsync({subcommand: 'remove'}) + toast.success('Remote removed.') + setDeleteDialogOpen(false) + } catch (error_) { + toast.error(formatError(error_, 'Failed to remove remote')) + } + } + + return ( + setEditing(true)} size="sm" variant="outline"> + Add remote + + ) + } + compact={!editing} + description="Used for push, pull, and fetch." + error={isError ? error : undefined} + errorFallback="Failed to load remote" + onRetry={() => refetch().catch(noop)} + title="Remotes" + > + {data ? ( + gitInitialized === false ? ( + } + description="Initialize version control before adding a remote." + title="Not initialized" + /> + ) : editing ? ( + setEditing(false)} + onSubmit={submit} + placeholder={urlPlaceholder} + webAppUrl={envConfig?.webAppUrl} + /> + ) : url ? ( + <> + setDeleteDialogOpen(true)} + onEdit={() => setEditing(true)} + url={url} + /> + + + ) : ( +

+ No remote set. Push and pull need an{' '} + origin URL. +

+ ) + ) : ( +
+ + + + +
+ )} +
+ ) +} diff --git a/src/webui/features/vc/components/settings-section.tsx b/src/webui/features/vc/components/settings-section.tsx new file mode 100644 index 000000000..e3efe2c5e --- /dev/null +++ b/src/webui/features/vc/components/settings-section.tsx @@ -0,0 +1,57 @@ +import type {ReactNode} from 'react' + +import {formatError} from '../../../lib/error-messages' + +type Props = { + action?: ReactNode + children?: ReactNode + compact?: boolean + description: string + error?: unknown + errorFallback?: string + onRetry?: () => void + title: string +} + +export function SettingsSection({ + action, + children, + compact = false, + description, + error, + errorFallback = 'Failed to load', + onRetry, + title, +}: Props) { + const cardClass = compact + ? 'bg-card flex flex-col gap-3 rounded-xl border px-4.5 py-3.5' + : 'bg-card flex flex-col gap-4 rounded-xl border px-5 py-4' + + return ( +
+
+
+

{title}

+

{description}

+
+ {action} +
+ + {error ? ( +

+ ✗ {formatError(error, errorFallback)} + {onRetry && ( + <> + {' · '} + + + )} +

+ ) : ( + children &&
{children}
+ )} +
+ ) +} diff --git a/src/webui/features/vc/utils/detect-git-url-type.ts b/src/webui/features/vc/utils/detect-git-url-type.ts new file mode 100644 index 000000000..6b2c4e308 --- /dev/null +++ b/src/webui/features/vc/utils/detect-git-url-type.ts @@ -0,0 +1,14 @@ +export type GitUrlType = 'git' | 'http' | 'https' | 'ssh' | 'unknown' + +const SCP_STYLE_RE = /^[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+:[^/].*$/ + +export function detectGitUrlType(value: string): GitUrlType { + const trimmed = value.trim() + if (trimmed === '') return 'unknown' + if (trimmed.startsWith('https://')) return 'https' + if (trimmed.startsWith('http://')) return 'http' + if (trimmed.startsWith('ssh://')) return 'ssh' + if (trimmed.startsWith('git://')) return 'git' + if (SCP_STYLE_RE.test(trimmed)) return 'ssh' + return 'unknown' +} diff --git a/src/webui/features/vc/utils/is-valid-email.ts b/src/webui/features/vc/utils/is-valid-email.ts new file mode 100644 index 000000000..f358e5b7b --- /dev/null +++ b/src/webui/features/vc/utils/is-valid-email.ts @@ -0,0 +1,7 @@ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +export function isValidEmail(value: string): boolean { + const trimmed = value.trim() + if (trimmed === '') return false + return EMAIL_RE.test(trimmed) +} diff --git a/src/webui/features/vc/utils/validate-remote-url.ts b/src/webui/features/vc/utils/validate-remote-url.ts new file mode 100644 index 000000000..c5d0a69bc --- /dev/null +++ b/src/webui/features/vc/utils/validate-remote-url.ts @@ -0,0 +1,19 @@ +import {detectGitUrlType} from './detect-git-url-type' + +/** + * Returns undefined if the URL is a valid HTTPS remote the webui can push to, + * otherwise returns a human-readable reason. SSH and git:// are intentionally + * rejected — the webui's push/pull path uses an HTTPS token, so other schemes + * won't work from the browser regardless of what git accepts. + */ +export function validateRemoteUrl(value: string): string | undefined { + const trimmed = value.trim() + if (trimmed === '') return 'URL is required.' + + const urlType = detectGitUrlType(trimmed) + if (urlType === 'ssh') return "SSH remotes aren't supported yet — use an HTTPS URL." + if (urlType === 'http') return "Plain HTTP isn't supported — use an HTTPS URL." + if (urlType !== 'https') return 'Expected an HTTPS URL (e.g. https://byterover.dev/team/space.git).' + + return undefined +} diff --git a/src/webui/index.html b/src/webui/index.html index 5c5a8b600..95875edd4 100644 --- a/src/webui/index.html +++ b/src/webui/index.html @@ -4,7 +4,7 @@ - ByteRover + ByteRover - Local
diff --git a/src/webui/layouts/header.tsx b/src/webui/layouts/header.tsx index 169715618..1453aa72d 100644 --- a/src/webui/layouts/header.tsx +++ b/src/webui/layouts/header.tsx @@ -1,10 +1,11 @@ import {Badge} from '@campfirein/byterover-packages/components/badge' import {Button} from '@campfirein/byterover-packages/components/button' import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' -import {Server} from 'lucide-react' +import {Plug} from 'lucide-react' import {useState} from 'react' import logo from '../assets/logo-byterover.svg' +import {StatusDot} from '../components/status-dot' import {AuthMenu} from '../features/auth/components/auth-menu' import {HelpMenu} from '../features/onboarding/components/help-menu' import {ProjectDropdown} from '../features/project/components/project-dropdown' @@ -58,14 +59,22 @@ export function Header() {
{/* Right: provider/model + docs + login */} -
+
setProviderDialogOpen(true)} size="sm" variant="ghost" />}> - + + + {activeProvider && ( + + )} + {providerLabel} - {!activeProvider && } + {!activeProvider && } - {!activeProvider && Configure to use curate/query feature} + Configure provider to power curate & query diff --git a/src/webui/layouts/main-layout.tsx b/src/webui/layouts/main-layout.tsx index 94eb2ed48..9bef9ee7d 100644 --- a/src/webui/layouts/main-layout.tsx +++ b/src/webui/layouts/main-layout.tsx @@ -1,11 +1,15 @@ import {Badge} from '@campfirein/byterover-packages/components/badge' import {cn} from '@campfirein/byterover-packages/lib/utils' -import {NavLink, Outlet} from 'react-router-dom' +import {NavLink, Outlet, useLocation} from 'react-router-dom' +import {TourBackdrop} from '../features/onboarding/components/tour-backdrop' import {TourBar} from '../features/onboarding/components/tour-bar' import {TourHost} from '../features/onboarding/components/tour-host' +import {TourPointer} from '../features/onboarding/components/tour-pointer' import {WelcomeOverlay} from '../features/onboarding/components/welcome-overlay' import {useTourWatchers} from '../features/onboarding/hooks/use-tour-watchers' +import {useOnboardingStore} from '../features/onboarding/stores/onboarding-store' +import {GlobalProviderDialog} from '../features/provider/components/global-provider-dialog' import {useTaskCounts} from '../features/tasks/stores/task-store' import {useGetVcStatus} from '../features/vc/api/get-vc-status' import {statusToFiles} from '../features/vc/utils/status-to-files' @@ -48,6 +52,13 @@ function ChangesBadge() { export function MainLayout() { const tabs = useTabs() useTourWatchers() + const tourActive = useOnboardingStore((s) => s.tourActive) + const tourStep = useOnboardingStore((s) => s.tourStep) + const tourTaskId = useOnboardingStore((s) => s.tourTaskId) + const {pathname} = useLocation() + const onTasksRoute = pathname.startsWith('/tasks') + const showTasksNavCue = + tourActive && (tourStep === 'curate' || tourStep === 'query') && !tourTaskId && !onTasksRoute return (
@@ -55,29 +66,40 @@ export function MainLayout() { {/* Tabs */} {/* Content */} @@ -87,7 +109,9 @@ export function MainLayout() { + +
) } diff --git a/src/webui/lib/error-messages.ts b/src/webui/lib/error-messages.ts index a88d619d9..9a7a98a77 100644 --- a/src/webui/lib/error-messages.ts +++ b/src/webui/lib/error-messages.ts @@ -1,3 +1,5 @@ +import {VcErrorCode} from '../../shared/transport/events/vc-events' + export interface ErrorContext { projectPath?: string } @@ -8,23 +10,21 @@ type OverrideValue = ((ctx: ErrorContext) => string) | string const OVERRIDES: Record = { // Auth / providers ERR_NOT_AUTHENTICATED: 'Please sign in to continue.', - ERR_PROVIDER_NOT_CONFIGURED: 'No provider is connected, or its credentials are missing or expired.', + // Version control - ERR_VC_ALREADY_INITIALIZED: 'Version control is already initialized for this project.', - ERR_VC_AUTH_FAILED: 'Authentication failed. Please sign in and try again.', - ERR_VC_BRANCH_NOT_FOUND: "Branch not found. You can create a new branch if needed.", - ERR_VC_NO_COMMITS: 'Make at least one commit before continuing.', - ERR_VC_NO_REMOTE: 'No remote is configured yet. Set one up before using pull, push, or fetch.', - ERR_VC_NO_UPSTREAM: - "This branch has no upstream configured yet. Use the Push button to publish it and set upstream in one step.", - ERR_VC_NON_FAST_FORWARD: 'The remote has changes. Pull first, then try again.', - ERR_VC_NOTHING_TO_PUSH: 'Nothing to push — stage and commit your changes first.', - ERR_VC_REMOTE_ALREADY_EXISTS: "A remote named 'origin' already exists. Remove or rename it before adding a new one.", - ERR_VC_USER_NOT_CONFIGURED: ({projectPath}) => - projectPath - ? `Please run \`brv vc config\` in "${projectPath}" to set your commit author before committing.` - : 'Please run `brv vc config` inside your project to set commit author before committing.', + [VcErrorCode.ALREADY_INITIALIZED]: 'Version control is already initialized for this project.', + [VcErrorCode.AUTH_FAILED]: 'Authentication failed. Please sign in and try again.', + [VcErrorCode.BRANCH_NOT_FOUND]: 'Branch not found. You can create a new branch if needed.', + [VcErrorCode.NO_COMMITS]: 'Make at least one commit before continuing.', + [VcErrorCode.NO_REMOTE]: 'No remote configured for this project.', + [VcErrorCode.NO_UPSTREAM]: + 'This branch has no upstream configured yet. Use the Push button to publish it and set upstream in one step.', + [VcErrorCode.NON_FAST_FORWARD]: 'The remote has changes. Pull first, then try again.', + [VcErrorCode.NOTHING_TO_PUSH]: 'Nothing to push — stage and commit your changes first.', + [VcErrorCode.REMOTE_ALREADY_EXISTS]: + "A remote named 'origin' already exists. Remove or rename it before adding a new one.", + [VcErrorCode.USER_NOT_CONFIGURED]: 'Commit author is not configured.', } const DEFAULT_FALLBACK = 'Something went wrong' diff --git a/src/webui/lib/noop.ts b/src/webui/lib/noop.ts new file mode 100644 index 000000000..b7505c326 --- /dev/null +++ b/src/webui/lib/noop.ts @@ -0,0 +1,2 @@ +/** Shared no-op; use as the argument to `.catch()` when the error is already surfaced elsewhere. */ +export const noop = () => {} diff --git a/src/webui/lib/toast-vc-error.ts b/src/webui/lib/toast-vc-error.ts new file mode 100644 index 000000000..695941902 --- /dev/null +++ b/src/webui/lib/toast-vc-error.ts @@ -0,0 +1,50 @@ +import type {NavigateFunction} from 'react-router-dom' + +import {toast} from 'sonner' + +import {VcErrorCode} from '../../shared/transport/events/vc-events' +import {type ErrorContext, formatError} from './error-messages' + +type ConfigCta = { + label: string + target: string +} + +const CONFIG_CTA: Record = { + [VcErrorCode.CONFIG_KEY_NOT_SET]: {label: 'Set identity', target: '/configuration'}, + [VcErrorCode.NO_REMOTE]: {label: 'Set remote', target: '/configuration'}, + [VcErrorCode.USER_NOT_CONFIGURED]: {label: 'Set identity', target: '/configuration'}, +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) return undefined + const {code} = error as {code: unknown} + return typeof code === 'string' ? code : undefined +} + +/** + * Surface a VC error as a toast. The message always comes from `formatError` + * (single source of truth); when the error code maps to a Configuration panel, + * a one-click CTA is attached that deep-links the user to that section. + */ +export function toastVcError( + error: unknown, + fallback: string, + navigate: NavigateFunction, + context: ErrorContext = {}, +): void { + const message = formatError(error, fallback, context) + const cta = CONFIG_CTA[errorCode(error) ?? ''] + + if (cta) { + toast.error(message, { + action: { + label: cta.label, + onClick: () => navigate(cta.target), + }, + }) + return + } + + toast.error(message) +} diff --git a/src/webui/lib/transport-error.ts b/src/webui/lib/transport-error.ts new file mode 100644 index 000000000..d23f23135 --- /dev/null +++ b/src/webui/lib/transport-error.ts @@ -0,0 +1,17 @@ +/** + * Shared helpers for inspecting errors that come off the transport layer. + * + * The daemon serializes errors as plain objects with an optional `code` field + * (see `serializeTaskError` / `VcError.toJSON()`), and socket.io passes them + * through to the client unchanged — they're NOT Error instances on arrival. + * Callers that need to branch on `error.code` should use this guard instead + * of open-coding the shape check. + */ +export function hasCode(error: unknown): error is {code: string} { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as {code: unknown}).code === 'string' + ) +} diff --git a/src/webui/pages/configuration-page.tsx b/src/webui/pages/configuration-page.tsx index db94bc559..619d72b95 100644 --- a/src/webui/pages/configuration-page.tsx +++ b/src/webui/pages/configuration-page.tsx @@ -1,7 +1,15 @@ -import { ConnectorsPanel } from '../features/connectors/components/connectors-panel' +import {ConnectorsPanel} from '../features/connectors/components/connectors-panel' +import {IdentityPanel} from '../features/vc/components/identity-panel' +import {RemotesPanel} from '../features/vc/components/remotes-panel' export function ConfigurationPage() { - return
- -
+ return ( +
+
+ + + +
+
+ ) } diff --git a/test/unit/infra/transport/handlers/locations-handler.test.ts b/test/unit/infra/transport/handlers/locations-handler.test.ts index 4d24b8c5d..4640d271d 100644 --- a/test/unit/infra/transport/handlers/locations-handler.test.ts +++ b/test/unit/infra/transport/handlers/locations-handler.test.ts @@ -85,10 +85,12 @@ describe('LocationsHandler', () => { } describe('setup', () => { - it('should register locations:get handler', () => { + it('should register locations:get and locations:reveal handlers', () => { createHandler() - expect(transport.onRequest.calledOnce).to.be.true - expect(transport.onRequest.firstCall.args[0]).to.equal(LocationsEvents.GET) + expect(transport.onRequest.calledTwice).to.be.true + const registeredEvents = transport.onRequest.getCalls().map((call) => call.args[0]) + expect(registeredEvents).to.include(LocationsEvents.GET) + expect(registeredEvents).to.include(LocationsEvents.REVEAL) }) }) diff --git a/test/unit/infra/transport/reveal-command.test.ts b/test/unit/infra/transport/reveal-command.test.ts new file mode 100644 index 000000000..9f888c569 --- /dev/null +++ b/test/unit/infra/transport/reveal-command.test.ts @@ -0,0 +1,30 @@ +import {expect} from 'chai' + +import {resolveRevealCommand} from '../../../../src/server/infra/transport/handlers/reveal-command.js' + +describe('resolveRevealCommand', () => { + const path = '/Users/wzlng/Documents/work/byterover-cli' + + it('returns the macOS "open" command on darwin', () => { + expect(resolveRevealCommand('darwin', path)).to.deep.equal({args: [path], command: 'open'}) + }) + + it('returns Windows "explorer" on win32', () => { + expect(resolveRevealCommand('win32', path)).to.deep.equal({args: [path], command: 'explorer'}) + }) + + it('falls back to xdg-open on linux', () => { + expect(resolveRevealCommand('linux', path)).to.deep.equal({args: [path], command: 'xdg-open'}) + }) + + it('falls back to xdg-open on freebsd and other POSIX-like platforms', () => { + expect(resolveRevealCommand('freebsd', path)).to.deep.equal({args: [path], command: 'xdg-open'}) + expect(resolveRevealCommand('openbsd', path)).to.deep.equal({args: [path], command: 'xdg-open'}) + }) + + it('passes the target path through as the first argument without interpolation', () => { + const hostile = '/tmp/dir with spaces/$(whoami)' + const result = resolveRevealCommand('darwin', hostile) + expect(result.args).to.deep.equal([hostile]) + }) +}) diff --git a/test/unit/infra/webui/webui-middleware.test.ts b/test/unit/infra/webui/webui-middleware.test.ts index 2b74aded8..2c1472f13 100644 --- a/test/unit/infra/webui/webui-middleware.test.ts +++ b/test/unit/infra/webui/webui-middleware.test.ts @@ -8,6 +8,7 @@ import {createWebUiMiddleware} from '../../../../src/server/infra/webui/webui-mi interface HttpResult { body: string + headers: IncomingMessage['headers'] status: number } @@ -17,7 +18,11 @@ async function httpRequest(url: string): Promise { const chunks: Buffer[] = [] res.on('data', (chunk: Buffer) => chunks.push(chunk)) res.on('end', () => { - resolve({body: Buffer.concat(chunks).toString('utf8'), status: res.statusCode ?? 0}) + resolve({ + body: Buffer.concat(chunks).toString('utf8'), + headers: res.headers, + status: res.statusCode ?? 0, + }) }) res.on('error', reject) }).on('error', reject) @@ -111,6 +116,21 @@ describe('createWebUiMiddleware', () => { expect(response.body).to.equal(indexHtml) }) + it('should allow https images in Content-Security-Policy for OAuth provider avatars', async () => { + const distRoot = join(testDir, 'dist', 'webui') + mkdirSync(distRoot, {recursive: true}) + writeFileSync(join(distRoot, 'index.html'), '', 'utf8') + + const port = await startServer(distRoot) + const response = await httpRequest(`http://127.0.0.1:${port}/`) + + const csp = response.headers['content-security-policy'] + expect(csp).to.be.a('string') + const imgSrc = (csp as string).split(';').map((d) => d.trim()).find((d) => d.startsWith('img-src')) + expect(imgSrc, 'img-src directive should be present').to.exist + expect(imgSrc).to.include('https:') + }) + it('should not register static or SPA routes when webuiDistDir does not exist', async () => { const missingRoot = join(testDir, 'does-not-exist') const port = await startServer(missingRoot) diff --git a/test/unit/webui/features/tasks/stores/composer-retry-store.test.ts b/test/unit/webui/features/tasks/stores/composer-retry-store.test.ts new file mode 100644 index 000000000..4565bd355 --- /dev/null +++ b/test/unit/webui/features/tasks/stores/composer-retry-store.test.ts @@ -0,0 +1,36 @@ +import {expect} from 'chai' + +import {useComposerRetryStore} from '../../../../../../src/webui/features/tasks/stores/composer-retry-store.js' + +describe('useComposerRetryStore', () => { + beforeEach(() => { + useComposerRetryStore.setState({seed: null}) + }) + + it('starts with a null seed', () => { + expect(useComposerRetryStore.getState().seed).to.equal(null) + }) + + it('records the latest seed via requestRetry', () => { + useComposerRetryStore.getState().requestRetry({content: 'list conventions', type: 'curate'}) + expect(useComposerRetryStore.getState().seed).to.deep.equal({content: 'list conventions', type: 'curate'}) + }) + + it('overwrites the previous seed when requestRetry is called again', () => { + useComposerRetryStore.getState().requestRetry({content: 'first', type: 'curate'}) + useComposerRetryStore.getState().requestRetry({content: 'second', type: 'query'}) + expect(useComposerRetryStore.getState().seed).to.deep.equal({content: 'second', type: 'query'}) + }) + + it('consume returns the seed and clears it', () => { + useComposerRetryStore.getState().requestRetry({content: 'hi', type: 'query'}) + const taken = useComposerRetryStore.getState().consume() + expect(taken).to.deep.equal({content: 'hi', type: 'query'}) + expect(useComposerRetryStore.getState().seed).to.equal(null) + }) + + it('consume returns null and is a no-op when there is no pending seed', () => { + expect(useComposerRetryStore.getState().consume()).to.equal(null) + expect(useComposerRetryStore.getState().seed).to.equal(null) + }) +}) diff --git a/test/unit/webui/features/tasks/utils/composer-type-from-task.test.ts b/test/unit/webui/features/tasks/utils/composer-type-from-task.test.ts new file mode 100644 index 000000000..b4d41ddbd --- /dev/null +++ b/test/unit/webui/features/tasks/utils/composer-type-from-task.test.ts @@ -0,0 +1,20 @@ +import {expect} from 'chai' + +import {composerTypeFromTask} from '../../../../../../src/webui/features/tasks/utils/composer-type-from-task.js' + +describe('composerTypeFromTask', () => { + it('maps query and search to query', () => { + expect(composerTypeFromTask('query')).to.equal('query') + expect(composerTypeFromTask('search')).to.equal('query') + }) + + it('maps curate and curate-folder to curate', () => { + expect(composerTypeFromTask('curate')).to.equal('curate') + expect(composerTypeFromTask('curate-folder')).to.equal('curate') + }) + + it('falls back to curate for unknown types so the composer still opens', () => { + expect(composerTypeFromTask('something-new')).to.equal('curate') + expect(composerTypeFromTask('')).to.equal('curate') + }) +}) diff --git a/test/unit/webui/features/tasks/utils/is-provider-task-error.test.ts b/test/unit/webui/features/tasks/utils/is-provider-task-error.test.ts new file mode 100644 index 000000000..75120a4e0 --- /dev/null +++ b/test/unit/webui/features/tasks/utils/is-provider-task-error.test.ts @@ -0,0 +1,46 @@ +import {expect} from 'chai' + +import {isProviderTaskError} from '../../../../../../src/webui/features/tasks/utils/is-provider-task-error' + +describe('isProviderTaskError', () => { + it('returns false for undefined error and no llmservice:error flag', () => { + expect(isProviderTaskError({error: undefined, hadLlmServiceError: false})).to.be.false + }) + + it('matches on provider-class task error codes', () => { + const codes = [ + 'ERR_PROVIDER_NOT_CONFIGURED', + 'ERR_LLM_ERROR', + 'ERR_LLM_RATE_LIMIT', + 'ERR_OAUTH_REFRESH_FAILED', + 'ERR_OAUTH_TOKEN_EXPIRED', + ] + for (const code of codes) { + expect( + isProviderTaskError({error: {code, message: 'x'}, hadLlmServiceError: false}), + `code=${code}`, + ).to.be.true + } + }) + + it('returns false for unrelated codes without llmservice:error', () => { + expect(isProviderTaskError({error: {code: 'ERR_TASK_TIMEOUT', message: 'x'}, hadLlmServiceError: false})).to.be + .false + expect(isProviderTaskError({error: {code: 'ERR_AGENT_DISCONNECTED', message: 'x'}, hadLlmServiceError: false})).to + .be.false + }) + + it('returns true when hadLlmServiceError is set, regardless of code or message', () => { + expect(isProviderTaskError({error: {message: 'anything at all'}, hadLlmServiceError: true})).to.be.true + expect(isProviderTaskError({error: undefined, hadLlmServiceError: true})).to.be.true + }) + + it('does not match on message text alone (no pattern heuristics)', () => { + expect( + isProviderTaskError({ + error: {message: 'Generation failed: rate limit — provider refused'}, + hadLlmServiceError: false, + }), + ).to.be.false + }) +}) diff --git a/test/unit/webui/features/vc/utils/detect-git-url-type.test.ts b/test/unit/webui/features/vc/utils/detect-git-url-type.test.ts new file mode 100644 index 000000000..98d546bcb --- /dev/null +++ b/test/unit/webui/features/vc/utils/detect-git-url-type.test.ts @@ -0,0 +1,34 @@ +import {expect} from 'chai' + +import {detectGitUrlType} from '../../../../../../src/webui/features/vc/utils/detect-git-url-type' + +describe('detectGitUrlType', () => { + it('detects https urls', () => { + expect(detectGitUrlType('https://github.com/wzlng/byterover-cli.git')).to.equal('https') + }) + + it('detects http urls distinctly from https', () => { + expect(detectGitUrlType('http://self-hosted.internal/repo.git')).to.equal('http') + }) + + it('detects ssh scheme urls', () => { + expect(detectGitUrlType('ssh://git@github.com/wzlng/byterover-cli.git')).to.equal('ssh') + }) + + it('detects git@host:path scp-style urls as ssh', () => { + expect(detectGitUrlType('git@github.com:wzlng/byterover-cli.git')).to.equal('ssh') + }) + + it('detects git:// urls', () => { + expect(detectGitUrlType('git://github.com/wzlng/byterover-cli.git')).to.equal('git') + }) + + it('returns unknown for empty / malformed strings', () => { + expect(detectGitUrlType('')).to.equal('unknown') + expect(detectGitUrlType('not-a-url')).to.equal('unknown') + }) + + it('trims surrounding whitespace before detecting', () => { + expect(detectGitUrlType(' git@github.com:foo/bar.git ')).to.equal('ssh') + }) +}) diff --git a/test/unit/webui/features/vc/utils/is-valid-email.test.ts b/test/unit/webui/features/vc/utils/is-valid-email.test.ts new file mode 100644 index 000000000..72f0eed9d --- /dev/null +++ b/test/unit/webui/features/vc/utils/is-valid-email.test.ts @@ -0,0 +1,30 @@ +import {expect} from 'chai' + +import {isValidEmail} from '../../../../../../src/webui/features/vc/utils/is-valid-email' + +describe('isValidEmail', () => { + it('accepts a typical email', () => { + expect(isValidEmail('john@byterover.dev')).to.be.true + }) + + it('accepts plus addressing and subdomains', () => { + expect(isValidEmail('john+commits@mail.byterover.dev')).to.be.true + }) + + it('rejects strings without an @', () => { + expect(isValidEmail('john-byterover.dev')).to.be.false + }) + + it('rejects strings without a TLD', () => { + expect(isValidEmail('john@localhost')).to.be.false + }) + + it('rejects empty strings and whitespace', () => { + expect(isValidEmail('')).to.be.false + expect(isValidEmail(' ')).to.be.false + }) + + it('trims surrounding whitespace before validating', () => { + expect(isValidEmail(' john@byterover.dev ')).to.be.true + }) +}) diff --git a/test/unit/webui/features/vc/utils/validate-remote-url.test.ts b/test/unit/webui/features/vc/utils/validate-remote-url.test.ts new file mode 100644 index 000000000..e28cd14a8 --- /dev/null +++ b/test/unit/webui/features/vc/utils/validate-remote-url.test.ts @@ -0,0 +1,48 @@ +import {expect} from 'chai' + +import {validateRemoteUrl} from '../../../../../../src/webui/features/vc/utils/validate-remote-url' + +describe('validateRemoteUrl', () => { + it('accepts a bare https URL', () => { + expect(validateRemoteUrl('https://github.com/wzlng/repo.git')).to.be.undefined + }) + + it('rejects plain http URLs with a specific message', () => { + expect(validateRemoteUrl('http://self-hosted.internal/repo.git')).to.equal( + "Plain HTTP isn't supported — use an HTTPS URL.", + ) + }) + + it('trims surrounding whitespace before validating', () => { + expect(validateRemoteUrl(' https://github.com/wzlng/repo.git ')).to.be.undefined + }) + + it('rejects empty/whitespace-only input', () => { + expect(validateRemoteUrl('')).to.equal('URL is required.') + expect(validateRemoteUrl(' ')).to.equal('URL is required.') + }) + + it('rejects scp-style ssh urls with a specific message', () => { + expect(validateRemoteUrl('git@github.com:wzlng/repo.git')).to.equal( + "SSH remotes aren't supported yet — use an HTTPS URL.", + ) + }) + + it('rejects ssh:// urls with a specific message', () => { + expect(validateRemoteUrl('ssh://git@github.com/wzlng/repo.git')).to.equal( + "SSH remotes aren't supported yet — use an HTTPS URL.", + ) + }) + + it('rejects git:// urls', () => { + expect(validateRemoteUrl('git://github.com/wzlng/repo.git')).to.equal( + 'Expected an HTTPS URL (e.g. https://byterover.dev/team/space.git).', + ) + }) + + it('rejects malformed non-URL strings', () => { + expect(validateRemoteUrl('foo/bar/repo')).to.equal( + 'Expected an HTTPS URL (e.g. https://byterover.dev/team/space.git).', + ) + }) +}) diff --git a/test/unit/webui/lib/error-messages.test.ts b/test/unit/webui/lib/error-messages.test.ts index e6bbb3ba3..5756ce4f8 100644 --- a/test/unit/webui/lib/error-messages.test.ts +++ b/test/unit/webui/lib/error-messages.test.ts @@ -39,21 +39,22 @@ describe('formatError', () => { expect(formatError({})).to.equal('Something went wrong') }) - describe('context-aware overrides', () => { - it('includes the project path in the USER_NOT_CONFIGURED override when context is provided', () => { + describe('USER_NOT_CONFIGURED override', () => { + // The override is now a plain string: the webui Configuration page is the + // remediation surface, so we don't interpolate project paths or reference + // `brv vc config` anymore. + it('returns the web-friendly message regardless of context', () => { const error = {code: 'ERR_VC_USER_NOT_CONFIGURED', message: 'raw server message'} - const result = formatError(error, 'fallback copy', {projectPath: '/Users/thien/my-proj'}) + const withCtx = formatError(error, 'fallback copy', {projectPath: '/Users/thien/my-proj'}) + const withoutCtx = formatError(error) - expect(result).to.include('/Users/thien/my-proj') - expect(result).to.include('brv vc config') + expect(withCtx).to.equal('Commit author is not configured.') + expect(withoutCtx).to.equal('Commit author is not configured.') }) - it('falls back to a generic USER_NOT_CONFIGURED override when no project path is supplied', () => { + it('does not leak `undefined` when no context is supplied', () => { const error = {code: 'ERR_VC_USER_NOT_CONFIGURED', message: 'raw server message'} - const result = formatError(error) - - expect(result).to.include('brv vc config') - expect(result).to.not.include('undefined') + expect(formatError(error)).to.not.include('undefined') }) it('ignores context for non-function overrides', () => { diff --git a/test/unit/webui/lib/toast-vc-error.test.ts b/test/unit/webui/lib/toast-vc-error.test.ts new file mode 100644 index 000000000..47244c8b2 --- /dev/null +++ b/test/unit/webui/lib/toast-vc-error.test.ts @@ -0,0 +1,70 @@ +import type {NavigateFunction} from 'react-router-dom' + +import {expect} from 'chai' +import {restore, type SinonStub, stub} from 'sinon' +import {toast} from 'sonner' + +import {VcErrorCode} from '../../../../src/shared/transport/events/vc-events.js' +import {toastVcError} from '../../../../src/webui/lib/toast-vc-error.js' + +type ToastAction = { + label: string + onClick: () => void +} + +function actionOf(call: SinonStub['firstCall']): ToastAction | undefined { + // toast.error(message, options?) — we only care about options.action here. + const options = call.args[1] as undefined | {action?: ToastAction} + return options?.action +} + +describe('toastVcError', () => { + let toastErrorStub: SinonStub + let navigate: NavigateFunction & SinonStub + + beforeEach(() => { + toastErrorStub = stub(toast, 'error') + navigate = stub() as unknown as NavigateFunction & SinonStub + }) + + afterEach(() => { + restore() + }) + + it('attaches a "Set identity" action for ERR_VC_CONFIG_KEY_NOT_SET', () => { + toastVcError( + {code: VcErrorCode.CONFIG_KEY_NOT_SET, message: 'raw'}, + 'fallback copy', + navigate, + ) + + expect(toastErrorStub.calledOnce).to.be.true + const action = actionOf(toastErrorStub.firstCall) + expect(action?.label).to.equal('Set identity') + + action?.onClick() + expect((navigate as unknown as SinonStub).calledOnceWith('/configuration')).to.be.true + }) + + it('attaches a "Set remote" action for ERR_VC_NO_REMOTE', () => { + toastVcError({code: VcErrorCode.NO_REMOTE, message: 'raw'}, 'fallback copy', navigate) + + expect(toastErrorStub.calledOnce).to.be.true + const action = actionOf(toastErrorStub.firstCall) + expect(action?.label).to.equal('Set remote') + + action?.onClick() + expect((navigate as unknown as SinonStub).calledOnceWith('/configuration')).to.be.true + }) + + it('calls toast.error with no action for an unknown error code', () => { + toastVcError({code: 'ERR_SOMETHING_ELSE', message: 'unrecognised'}, 'fallback copy', navigate) + + expect(toastErrorStub.calledOnce).to.be.true + // Second arg should be either undefined or a plain options object without + // an `action` field — there's nothing to navigate to. + const options = toastErrorStub.firstCall.args[1] as undefined | {action?: ToastAction} + expect(options?.action).to.be.undefined + expect((navigate as unknown as SinonStub).called).to.be.false + }) +})