diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 63912924..c64efd30 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,6 +3,7 @@ import { CircleNotch } from "./components/ui/icons"; import { LocaleContext, TranslationContext, type TranslationFn } from "./i18n"; import type { AppLocale } from "@shared/locale"; import { CompanyOnboarding } from "./components/company-onboarding/OnboardingWizard"; +import { OAuthPromptDialog } from "./components/settings/dialogs/OAuthPromptDialog"; import TitleBar from "./components/TitleBar"; import ToastContainer from "./components/Toast"; import { SidebarView } from "./views/SidebarView"; @@ -147,6 +148,9 @@ export function App() { isFirstLaunch={company.snapshot.companies.length === 0} /> + {/* Mount here so onboarding OAuth flows that need a prompt (e.g. */} + {/* GitHub Copilot's enterprise-domain step) can surface a modal. */} + ); } @@ -220,6 +224,9 @@ export function App() { /> + {/* App-root mount so any section that kicks off an OAuth login — */} + {/* not just Settings — sees prompt/manual-code events. */} + diff --git a/src/renderer/components/communication/MessageItem.tsx b/src/renderer/components/communication/MessageItem.tsx index ff6c1872..c7bbc521 100644 --- a/src/renderer/components/communication/MessageItem.tsx +++ b/src/renderer/components/communication/MessageItem.tsx @@ -13,6 +13,7 @@ import { getAgentInitials, formatMessageTime } from "../../lib/formatters" import { safeJson } from "../../lib/safe-json" import { DEPARTMENT_COLORS } from "@shared/constants" import { useT, type TranslationFn } from "../../i18n" +import { MarkdownBody } from "../ui/MarkdownBody" const HUMAN_SENDER_ID = "__board__"; @@ -119,8 +120,8 @@ export const MessageBubble = memo(function MessageBubble({ ) : null} -
- {message.body} +
+
{message.attachmentsJson && message.attachmentsJson !== "[]" ? ( @@ -179,8 +180,8 @@ export function TaskCommentBubble({ {agent?.title ? {agent.title} : null} {formatMessageTime(comment.createdAt)}
-
- {comment.body} +
+
diff --git a/src/renderer/components/company-onboarding/CompanyStep.tsx b/src/renderer/components/company-onboarding/CompanyStep.tsx index 6b4ae3c9..4fa7ab2d 100644 --- a/src/renderer/components/company-onboarding/CompanyStep.tsx +++ b/src/renderer/components/company-onboarding/CompanyStep.tsx @@ -72,12 +72,14 @@ export function CompanyStep({ rows={3} /> - {/* CEO Provider + Model */} + {/* CEO Provider + Model — stacked so long names (`Fireworks AI`, + `Kimi K2.5 Turbo Preview 0911`) are readable without the + two-column ellipsis truncation that the 540-px panel forced. */}
{currentLocale === "zh" ? "CEO 运行时" : "CEO Runtime"}
-
+
+
+
+ +
void handlePick()} diff --git a/src/renderer/components/company-onboarding/ModelPoolStep.tsx b/src/renderer/components/company-onboarding/ModelPoolStep.tsx index ecf646e7..0ee4e9ab 100644 --- a/src/renderer/components/company-onboarding/ModelPoolStep.tsx +++ b/src/renderer/components/company-onboarding/ModelPoolStep.tsx @@ -16,6 +16,7 @@ export function ModelPoolStep({ onBack, onContinue }: ModelPoolStepProps) { const { connectorModelPools, onToggleConnectorModel, + onSetAllConnectorModels, selectedConnectorId, selectedConnectorEnabledModels, totalEnabledModels, @@ -44,31 +45,54 @@ export function ModelPoolStep({ onBack, onContinue }: ModelPoolStepProps) {
{connectorModelPools.map(({ connector, models }) => { const enabledCount = models.filter((model) => model.enabled).length; + const allEnabled = models.length > 0 && enabledCount === models.length; + // One action, state-aware: when everything is on the next useful + // move is "disable all"; otherwise the user almost always wants + // "enable all". Two buttons would compete for the user's decision + // for no real benefit. + const bulkAction = allEnabled + ? { label: t("models.disableAll"), target: false } + : { label: t("models.enableAll"), target: true }; return (
-
-
-
- - {connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label} + {/* Row 1 — identity + health. Title has room to breathe. */} +
+
+ + {connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label} + + {connector.id === selectedConnectorId ? ( + + {t("models.ceoRuntime")} - {connector.id === selectedConnectorId ? ( - - {t("models.ceoRuntime")} - - ) : null} -
-
- {t("models.poolSummary").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length || 0))} -
+ ) : null}
- void onToggleConnectorModel(connector.id, modelId)} - canRemove={false} - /> + {/* Row 2 — stats + bulk action. Only rendered when there is */} + {/* more than one model (single-model providers have nothing */} + {/* meaningful to bulk-toggle). */} +
+ + {t("models.poolSummary").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length || 0))} + + {models.length > 1 ? ( + + ) : null} +
+
+ void onToggleConnectorModel(connector.id, modelId)} + canRemove={false} + /> +
); })} diff --git a/src/renderer/components/company-onboarding/OnboardingContext.tsx b/src/renderer/components/company-onboarding/OnboardingContext.tsx index d22cf41f..68afdf89 100644 --- a/src/renderer/components/company-onboarding/OnboardingContext.tsx +++ b/src/renderer/components/company-onboarding/OnboardingContext.tsx @@ -76,6 +76,7 @@ export interface OnboardingConnectorsContextValue { totalEnabledModels: number; selectedConnectorEnabledModels: number; onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void; + onSetAllConnectorModels: (connectorId: ConnectorId, enabled: boolean) => void; } const OnboardingConnectorsContext = createContext(null); diff --git a/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx b/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx index 22a756d7..9c005194 100644 --- a/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx +++ b/src/renderer/components/company-onboarding/OnboardingStepPanel.tsx @@ -1,6 +1,14 @@ import { AnimatePresence } from "framer-motion"; import type { ExecutionMode } from "@shared/types"; -import { OnboardingHero, type OnboardingStep } from "./OnboardingHero"; +import { + Buildings, + CheckCircle, + HardDrive, + Plugs, + SlidersHorizontal, + Target, +} from "../ui/icons"; +import { OnboardingHero, STEP_ORDER, type OnboardingStep, type OnboardingStepName } from "./OnboardingHero"; import { NodeCheckStep } from "./NodeCheckStep"; import { ProvidersStep } from "./ProvidersStep"; import { CompanyStep, type CompanyStepValues } from "./CompanyStep"; @@ -10,12 +18,35 @@ import { MissionStep, type MissionStepValues } from "./MissionStep"; import { LaunchStep } from "./LaunchStep"; import { useOnboardingWizard } from "./OnboardingContext"; +/** + * Completion flag per tab. Computed in OnboardingWizard from the + * wizard's finalized values + connector slice, threaded down here so + * the tab bar can show a tick next to finished sections and the user + * can tell at a glance what is still blocking launch. `launch` is + * implied complete once all six earlier tabs are true; it has no + * independent field. + */ +export interface OnboardingTabCompletion { + nodecheck: boolean; + providers: boolean; + company: boolean; + modelpool: boolean; + executionmode: boolean; + mission: boolean; +} + interface OnboardingStepPanelProps { step: OnboardingStep; canCancel: boolean; onCancel?: () => void; isFirstLaunch: boolean; onBack: (step: OnboardingStep) => void; + /** + * Jump directly to an arbitrary setup tab. Called by the tab bar; + * unlike `onBack` it is symmetric (can move forward or backward). + */ + onNavigateToStep: (step: OnboardingStepName) => void; + tabCompletion: OnboardingTabCompletion; onContinueFromNodeCheck: () => void; onContinueFromProviders: () => void; onContinueFromCompany: (values: CompanyStepValues) => void; @@ -29,12 +60,33 @@ interface OnboardingStepPanelProps { executionMode: ExecutionMode; } +type SetupTabId = Exclude; + +const SETUP_TABS: ReadonlyArray<{ + id: SetupTabId; + icon: typeof HardDrive; + labelKey: string; +}> = [ + { id: "nodecheck", icon: HardDrive, labelKey: "onboarding.stepLabelNodeCheck" }, + { id: "providers", icon: Plugs, labelKey: "onboarding.stepLabelProviders" }, + { id: "company", icon: Buildings, labelKey: "onboarding.stepLabelCompany" }, + { id: "modelpool", icon: SlidersHorizontal, labelKey: "onboarding.stepLabelModelPool" }, + { id: "executionmode", icon: CheckCircle, labelKey: "onboarding.stepLabelMode" }, + { id: "mission", icon: Target, labelKey: "onboarding.stepLabelMission" }, +]; + +function isSetupTabId(step: OnboardingStep): step is SetupTabId { + return (STEP_ORDER as readonly OnboardingStep[]).includes(step) && step !== "launch"; +} + export function OnboardingStepPanel({ step, canCancel, onCancel, isFirstLaunch, onBack, + onNavigateToStep, + tabCompletion, onContinueFromNodeCheck, onContinueFromProviders, onContinueFromCompany, @@ -48,13 +100,14 @@ export function OnboardingStepPanel({ executionMode, }: OnboardingStepPanelProps) { const { currentLocale, t, launchSummary } = useOnboardingWizard(); + const showTabs = isSetupTabId(step); return ( <>
-
+
{canCancel && onCancel && !isFirstLaunch ? ( + ); + })} + + ) : null} + +
{step === "nodecheck" ? ( ) : null} +
diff --git a/src/renderer/components/company-onboarding/OnboardingWizard.tsx b/src/renderer/components/company-onboarding/OnboardingWizard.tsx index cce471dc..b731c77f 100644 --- a/src/renderer/components/company-onboarding/OnboardingWizard.tsx +++ b/src/renderer/components/company-onboarding/OnboardingWizard.tsx @@ -8,9 +8,9 @@ import type { import type { AppLocale } from "@shared/locale"; import { useT } from "../../i18n"; import { unwrap } from "../../lib/desktop"; -import type { OnboardingStep } from "./OnboardingHero"; +import type { OnboardingStep, OnboardingStepName } from "./OnboardingHero"; import { WelcomeStep } from "./WelcomeStep"; -import { OnboardingStepPanel } from "./OnboardingStepPanel"; +import { OnboardingStepPanel, type OnboardingTabCompletion } from "./OnboardingStepPanel"; import { useConnectorCallbacks } from "./useConnectorCallbacks"; import { useConnectorModelSelection } from "./useConnectorModelSelection"; import { @@ -100,6 +100,7 @@ export function CompanyOnboarding({ selectedConnectorEnabledModels, totalEnabledModels, toggleConnectorModel, + setAllConnectorModels, } = useConnectorModelSelection(connectors, currentLocale, t, setError); const pickDirectory = useCallback(async (): Promise => { @@ -221,6 +222,8 @@ export function CompanyOnboarding({ selectedConnectorEnabledModels, onToggleConnectorModel: (connectorId, modelId) => void toggleConnectorModel(connectorId, modelId), + onSetAllConnectorModels: (connectorId, enabled) => + void setAllConnectorModels(connectorId, enabled), }), [ connectors, @@ -239,12 +242,43 @@ export function CompanyOnboarding({ totalEnabledModels, selectedConnectorEnabledModels, toggleConnectorModel, + setAllConnectorModels, ], ); const isWelcome = step === "welcome"; const heroStep: OnboardingStep = step as OnboardingStep; + // Completion flags drive the tab ticks. Each rule mirrors the + // validation that the in-step "Continue" button enforces, so a user + // who navigates tabs freely cannot land on "mission" with a green + // "company" tick unless company data is actually valid. `launch` + // does not get a tab (it has no independent field; it just consumes + // the others). + const navigateToStep = useCallback((target: OnboardingStepName) => { + setError(null); + setStep(target); + }, []); + + const tabCompletion: OnboardingTabCompletion = useMemo( + () => ({ + nodecheck: Boolean(nodeStatus?.installed), + providers: hasAtLeastOneReady, + company: companyValues.companyName.trim().length > 0 && selectedConnectorId !== "", + modelpool: totalEnabledModels > 0, + executionmode: true, // has a default ("ai_ceo") + mission: missionValues.goalTitle.trim().length > 0, + }), + [ + nodeStatus?.installed, + hasAtLeastOneReady, + companyValues.companyName, + selectedConnectorId, + totalEnabledModels, + missionValues.goalTitle, + ], + ); + return (
{ setError(null); setStep("providers"); diff --git a/src/renderer/components/company-onboarding/useConnectorModelSelection.ts b/src/renderer/components/company-onboarding/useConnectorModelSelection.ts index 9a042cb8..04dbbee3 100644 --- a/src/renderer/components/company-onboarding/useConnectorModelSelection.ts +++ b/src/renderer/components/company-onboarding/useConnectorModelSelection.ts @@ -54,6 +54,7 @@ interface UseConnectorModelSelectionResult { totalEnabledModels: number; selectedConnectorEnabledModels: number; toggleConnectorModel: (connectorId: ConnectorId, modelId: string) => Promise; + setAllConnectorModels: (connectorId: ConnectorId, enabled: boolean) => Promise; } export function useConnectorModelSelection( @@ -265,6 +266,42 @@ export function useConnectorModelSelection( } }, [connectorModelsById, currentLocale, setError, t]); + /** + * Bulk-flip every model of one connector on or off. Uses the same + * single-call `setConnectorDisabledModels` IPC as the individual + * toggle so the UI and the DB agree after one round-trip. + * + * Disabling-all is allowed even if it zeroes the global enabled + * count — the continue gate at the wizard level catches that and + * blocks the next step, matching the single-toggle behavior. + */ + const setAllConnectorModels = useCallback( + async (connectorId: ConnectorId, enabled: boolean) => { + const connectorModels = connectorModelsById[connectorId]; + if (!connectorModels || connectorModels.length === 0) return; + + const updatedModels = connectorModels.map((model) => ({ ...model, enabled })); + const previousModels = connectorModelsById; + setConnectorModelsById({ ...connectorModelsById, [connectorId]: updatedModels }); + setError(null); + + try { + unwrap( + await window.agentCompany.setConnectorDisabledModels({ + connectorId, + disabledModels: enabled + ? [] + : updatedModels.map((model) => ({ id: model.id, label: model.label })), + }), + ); + } catch (error) { + setConnectorModelsById(previousModels); + setError(error instanceof Error ? error.message : t("onboarding.unableToBootstrap")); + } + }, + [connectorModelsById, setError, t], + ); + return { executionReadyConnectors, hasAtLeastOneReady, @@ -280,5 +317,6 @@ export function useConnectorModelSelection( totalEnabledModels, selectedConnectorEnabledModels, toggleConnectorModel, + setAllConnectorModels, }; } diff --git a/src/renderer/components/execution/ModelManagementShared.tsx b/src/renderer/components/execution/ModelManagementShared.tsx index a06f2810..229c19fe 100644 --- a/src/renderer/components/execution/ModelManagementShared.tsx +++ b/src/renderer/components/execution/ModelManagementShared.tsx @@ -30,7 +30,7 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) { const t = useT(); if (status === "ready") { return ( - + {t("models.ready")} @@ -38,7 +38,7 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) { } if (status === "not_installed") { return ( - + {t("models.notInstalled")} @@ -46,7 +46,7 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) { } if (status === "auth_required") { return ( - + {t("models.authRequired")} @@ -54,14 +54,14 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) { } if (status === "detected" || (status as string) === "checking") { return ( - + {t("models.checking")} ); } return ( - + {status.replaceAll("_", " ")} @@ -153,12 +153,8 @@ export function ModelToggleList({
); } - const enabledCount = models.filter((m) => m.enabled).length; return (
-
- {t("models.label")} ({t("models.enabledCount").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length))}) -
{models.map((m) => (
{agent.role}} {formatTime(comment.createdAt)}
-
{comment.body}
+
+ +
) })} diff --git a/src/renderer/components/ui/ActionButton.tsx b/src/renderer/components/ui/ActionButton.tsx index f3525619..9075666d 100644 --- a/src/renderer/components/ui/ActionButton.tsx +++ b/src/renderer/components/ui/ActionButton.tsx @@ -39,7 +39,7 @@ export function ActionButton({ type={type} aria-label={iconOnly ? (ariaLabel ?? label) : ariaLabel} title={iconOnly ? label : undefined} - className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center rounded-lg border text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${sizing} ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`} + className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex shrink-0 items-center whitespace-nowrap rounded-lg border text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${sizing} ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`} onClick={onClick} disabled={isDisabled} aria-busy={loading} @@ -49,7 +49,7 @@ export function ActionButton({ ) : Icon ? ( ) : null} - {iconOnly ? null : {label}} + {iconOnly ? null : {label}} ); } diff --git a/src/renderer/components/ui/MarkdownBody.tsx b/src/renderer/components/ui/MarkdownBody.tsx new file mode 100644 index 00000000..c7e5ac8c --- /dev/null +++ b/src/renderer/components/ui/MarkdownBody.tsx @@ -0,0 +1,82 @@ +import { memo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +/** + * Read-only markdown renderer used by message bubbles, comment bodies, and + * other agent-authored text. AI agents consistently emit GFM flavour (bold, + * bullet lists, numbered lists, inline code, fenced blocks, tables), so we + * ship those plus strikethrough and task lists. + * + * Security: `react-markdown` disables HTML by default. We do not pass the + * `rehype-raw` plugin, so any `