ui(onboarding): flat tab navigation + fix vertical-stacked button text
Onboarding was a 7-step linear wizard, same information hierarchy as Settings but behaving like a modal. Replace with the SettingsShell tab pattern: one row of six tabs (nodecheck / providers / company / modelpool / executionmode / mission), free navigation, per-tab completion ticks derived from the wizard's own validation rules. Sub-fixes on the same pass: - ActionButton: add whitespace-nowrap + shrink-0 so "选择" never stacks vertically on narrow containers. Global — every caller gets the correct behavior - CompanyStep: CEO Runtime Provider/Model go vertical (space-y-3 instead of grid-cols-2) so long names like "Fireworks AI" / "Kimi K2.5 Turbo Preview 0911" stay readable; workspace-path Input wrapped in min-w-0 flex-1 container so it yields, not the button - MarkdownBody: render react-markdown in chat bubbles (was plain text) i18n: added onboarding.stepsAria for the tablist label.
This commit is contained in:
1 parent
ff5e22092a
commit
c83d670050
16 files changed
+352
-60
No files matched your search
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
{/* Mount here so onboarding OAuth flows that need a prompt (e.g. */}
|
||||
{/* GitHub Copilot's enterprise-domain step) can surface a modal. */}
|
||||
<OAuthPromptDialog />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -220,6 +224,9 @@ export function App() {
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
{/* App-root mount so any section that kicks off an OAuth login — */}
|
||||
{/* not just Settings — sees prompt/manual-code events. */}
|
||||
<OAuthPromptDialog />
|
||||
</div>
|
||||
</div>
|
||||
</SectionContext.Provider>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-1 break-words whitespace-pre-wrap text-[13px] leading-relaxed text-muted-foreground">
|
||||
{message.body}
|
||||
<div className="mt-1 break-words text-[13px] leading-relaxed text-muted-foreground">
|
||||
<MarkdownBody body={message.body} />
|
||||
</div>
|
||||
|
||||
{message.attachmentsJson && message.attachmentsJson !== "[]" ? (
|
||||
@@ -179,8 +180,8 @@ export function TaskCommentBubble({
|
||||
{agent?.title ? <span className="truncate text-[11px] text-muted-foreground">{agent.title}</span> : null}
|
||||
<span className="whitespace-nowrap shrink-0 text-[11px] text-muted-foreground">{formatMessageTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-words whitespace-pre-wrap text-[13px] leading-relaxed text-muted-foreground">
|
||||
{comment.body}
|
||||
<div className="mt-1 break-words text-[13px] leading-relaxed text-muted-foreground">
|
||||
<MarkdownBody body={comment.body} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -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. */}
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-medium text-muted-foreground">
|
||||
{currentLocale === "zh" ? "CEO 运行时" : "CEO Runtime"}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
label={currentLocale === "zh" ? "提供商" : "Provider"}
|
||||
value={selectedConnectorId}
|
||||
@@ -114,14 +116,16 @@ export function CompanyStep({
|
||||
{t("onboarding.workspaceFolderOptional")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
id="onboarding-workspace-path"
|
||||
ariaLabel={t("onboarding.workspaceFolder")}
|
||||
value={workspacePath}
|
||||
onChange={setWorkspacePath}
|
||||
placeholder={t("onboarding.workspaceFolderPlaceholder")}
|
||||
/>
|
||||
<div className="flex items-stretch gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Input
|
||||
id="onboarding-workspace-path"
|
||||
ariaLabel={t("onboarding.workspaceFolder")}
|
||||
value={workspacePath}
|
||||
onChange={setWorkspacePath}
|
||||
placeholder={t("onboarding.workspaceFolderPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
label={currentLocale === "zh" ? "选择" : "Choose"}
|
||||
onClick={() => void handlePick()}
|
||||
|
||||
@@ -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) {
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div key={connector.id} className="rounded-lg border border-border bg-background/50 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold text-foreground">
|
||||
{connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label}
|
||||
{/* Row 1 — identity + health. Title has room to breathe. */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold text-foreground">
|
||||
{connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label}
|
||||
</span>
|
||||
{connector.id === selectedConnectorId ? (
|
||||
<span className="shrink-0 whitespace-nowrap rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-primary">
|
||||
{t("models.ceoRuntime")}
|
||||
</span>
|
||||
{connector.id === selectedConnectorId ? (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-primary">
|
||||
{t("models.ceoRuntime")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{t("models.poolSummary").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length || 0))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusBadge status={connector.status} />
|
||||
</div>
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(modelId) => 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). */}
|
||||
<div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t("models.poolSummary").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length || 0))}
|
||||
</span>
|
||||
{models.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetAllConnectorModels(connector.id, bulkAction.target)}
|
||||
className="whitespace-nowrap rounded-md border border-border bg-background/60 px-2 py-1 text-[11px] text-muted-foreground transition hover:border-foreground/40 hover:text-foreground"
|
||||
>
|
||||
{bulkAction.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(modelId) => void onToggleConnectorModel(connector.id, modelId)}
|
||||
canRemove={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<OnboardingConnectorsContextValue | null>(null);
|
||||
|
||||
@@ -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<OnboardingStepName, "launch">;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<OnboardingHero step={step} currentLocale={currentLocale} t={t} />
|
||||
|
||||
<div className="min-h-0 overflow-y-auto border-l border-border bg-background">
|
||||
<div className="relative flex min-h-full items-center px-6 py-10 md:px-10">
|
||||
<div className="relative flex min-h-full flex-col px-6 py-10 md:px-10">
|
||||
{canCancel && onCancel && !isFirstLaunch ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -65,6 +118,50 @@ export function OnboardingStepPanel({
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{showTabs ? (
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label={t("onboarding.stepsAria")}
|
||||
className="mb-6 flex flex-wrap gap-1 border-b border-border"
|
||||
>
|
||||
{SETUP_TABS.map((tab) => {
|
||||
const active = step === tab.id;
|
||||
const done = tabCompletion[tab.id];
|
||||
const Icon = done ? CheckCircle : tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onNavigateToStep(tab.id)}
|
||||
className={`relative flex items-center gap-1.5 whitespace-nowrap px-3 py-2 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||
active
|
||||
? "text-foreground"
|
||||
: done
|
||||
? "text-success hover:text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
size={13}
|
||||
weight={done ? "fill" : "regular"}
|
||||
className={done && !active ? "text-success" : undefined}
|
||||
/>
|
||||
<span>{t(tab.labelKey)}</span>
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-2 bottom-[-1px] h-[2px] bg-primary"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-1 items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === "nodecheck" ? (
|
||||
<NodeCheckStep
|
||||
@@ -116,6 +213,7 @@ export function OnboardingStepPanel({
|
||||
<LaunchStep onFinish={onFinishLaunch} />
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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<string | null> => {
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`relative h-[100dvh] overflow-hidden bg-background ${
|
||||
@@ -269,6 +303,8 @@ export function CompanyOnboarding({
|
||||
onCancel={onCancel}
|
||||
isFirstLaunch={isFirstLaunch}
|
||||
onBack={setStep}
|
||||
onNavigateToStep={navigateToStep}
|
||||
tabCompletion={tabCompletion}
|
||||
onContinueFromNodeCheck={() => {
|
||||
setError(null);
|
||||
setStep("providers");
|
||||
|
||||
@@ -54,6 +54,7 @@ interface UseConnectorModelSelectionResult {
|
||||
totalEnabledModels: number;
|
||||
selectedConnectorEnabledModels: number;
|
||||
toggleConnectorModel: (connectorId: ConnectorId, modelId: string) => Promise<void>;
|
||||
setAllConnectorModels: (connectorId: ConnectorId, enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) {
|
||||
const t = useT();
|
||||
if (status === "ready") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
{t("models.ready")}
|
||||
</span>
|
||||
@@ -38,7 +38,7 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) {
|
||||
}
|
||||
if (status === "not_installed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
|
||||
{t("models.notInstalled")}
|
||||
</span>
|
||||
@@ -46,7 +46,7 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) {
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
{t("models.authRequired")}
|
||||
</span>
|
||||
@@ -54,14 +54,14 @@ export function StatusBadge({ status }: { status: ConnectorStatus }) {
|
||||
}
|
||||
if (status === "detected" || (status as string) === "checking") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
|
||||
{t("models.checking")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
@@ -153,12 +153,8 @@ export function ModelToggleList({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const enabledCount = models.filter((m) => m.enabled).length;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
|
||||
{t("models.label")} ({t("models.enabledCount").replace("{enabled}", String(enabledCount)).replace("{total}", String(models.length))})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{models.map((m) => (
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useT } from "../../i18n"
|
||||
import { formatTime } from "../../lib/formatters"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { ActionButton, InlineNotice } from "../ui"
|
||||
import { MarkdownBody } from "../ui/MarkdownBody"
|
||||
import { describeActionError } from "./execution-utils"
|
||||
|
||||
export function TaskComments({ taskId, companyId, agents, comments, onRefresh }: {
|
||||
@@ -44,7 +45,9 @@ export function TaskComments({ taskId, companyId, agents, comments, onRefresh }:
|
||||
{agent && <span className="text-[10px] text-muted-foreground">{agent.role}</span>}
|
||||
<span className="text-[10px] text-muted-foreground">{formatTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap text-[13px] text-foreground">{comment.body}</div>
|
||||
<div className="mt-1 text-[13px] text-foreground">
|
||||
<MarkdownBody body={comment.body} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 ? (
|
||||
<Icon size={16} />
|
||||
) : null}
|
||||
{iconOnly ? null : <span>{label}</span>}
|
||||
{iconOnly ? null : <span className="whitespace-nowrap">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 `<script>` / inline SVG in a message body is
|
||||
* rendered as literal text. Links open in the user's default browser via
|
||||
* Electron's window behaviour; we annotate them with `target="_blank"` and
|
||||
* `rel="noreferrer"` as a belt-and-suspenders guard against clipboard
|
||||
* spoofing.
|
||||
*
|
||||
* Style tokens stay aligned with `text-muted-foreground` bodies elsewhere —
|
||||
* the renderer overrides only the elements markdown introduces (lists,
|
||||
* code, tables, headings), inheriting the color / size / leading from the
|
||||
* wrapping container.
|
||||
*/
|
||||
export const MarkdownBody = memo(function MarkdownBody({ body }: { body: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
ul: ({ children }) => <ul className="mb-2 ml-4 list-disc space-y-0.5 last:mb-0 marker:text-muted-foreground/60">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="mb-2 ml-4 list-decimal space-y-0.5 last:mb-0 marker:text-muted-foreground/60">{children}</ol>,
|
||||
li: ({ children }) => <li className="pl-1">{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
del: ({ children }) => <del className="text-muted-foreground/60">{children}</del>,
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = (className ?? "").startsWith("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className="block whitespace-pre overflow-x-auto rounded-md bg-muted/70 px-3 py-2 font-mono text-[12px] leading-relaxed text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="rounded-sm bg-muted/70 px-1 py-0.5 font-mono text-[12px] text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => <pre className="mb-2 last:mb-0">{children}</pre>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-2 border-l-2 border-border pl-3 italic last:mb-0">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline decoration-dotted underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
h1: ({ children }) => <h1 className="mb-1 mt-2 text-[14px] font-semibold text-foreground first:mt-0">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-1 mt-2 text-[13px] font-semibold text-foreground first:mt-0">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-1 mt-2 text-[13px] font-semibold text-foreground first:mt-0">{children}</h3>,
|
||||
table: ({ children }) => (
|
||||
<div className="mb-2 overflow-x-auto last:mb-0">
|
||||
<table className="w-full border-collapse text-[12px]">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th className="border border-border bg-muted/40 px-2 py-1 text-left font-semibold text-foreground">{children}</th>,
|
||||
td: ({ children }) => <td className="border border-border px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export function StatusPill({ status }: { status: string }) {
|
||||
const displayLabel = checking ? "checking" : status.replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.12em] ${toneClass}`}>
|
||||
<span className={`inline-flex shrink-0 items-center gap-2 whitespace-nowrap text-[11px] font-bold uppercase tracking-[0.12em] ${toneClass}`}>
|
||||
{checking ? (
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[color:var(--muted-foreground)]" aria-hidden="true" />
|
||||
) : (
|
||||
|
||||
@@ -117,6 +117,7 @@ export const en: Record<string, string> = {
|
||||
"onboarding.selectLanguage": "Select language",
|
||||
"onboarding.welcomeSubtitle": "Build your AI company here.",
|
||||
"onboarding.getStarted": "Get Started",
|
||||
"onboarding.stepsAria": "Setup sections",
|
||||
"onboarding.stepLabelNodeCheck": "Requirements",
|
||||
"onboarding.stepLabelProviders": "Providers",
|
||||
"onboarding.stepLabelCompany": "Company",
|
||||
@@ -1307,6 +1308,8 @@ export const en: Record<string, string> = {
|
||||
"models.disableToggle": "Disable {name}",
|
||||
"models.ceoRuntime": "CEO runtime",
|
||||
"models.poolSummary": "{enabled} / {total} models enabled",
|
||||
"models.enableAll": "Enable all",
|
||||
"models.disableAll": "Disable all",
|
||||
|
||||
// ── Title bar ──
|
||||
"titlebar.minimize": "Minimize",
|
||||
|
||||
@@ -117,6 +117,7 @@ export const zh: Record<string, string> = {
|
||||
"onboarding.selectLanguage": "选择语言",
|
||||
"onboarding.welcomeSubtitle": "从这里开始搭你的 AI 公司。",
|
||||
"onboarding.getStarted": "开始设置",
|
||||
"onboarding.stepsAria": "配置步骤",
|
||||
"onboarding.stepLabelNodeCheck": "环境检查",
|
||||
"onboarding.stepLabelProviders": "AI 提供商",
|
||||
"onboarding.stepLabelCompany": "公司",
|
||||
@@ -1307,6 +1308,8 @@ export const zh: Record<string, string> = {
|
||||
"models.disableToggle": "禁用 {name}",
|
||||
"models.ceoRuntime": "CEO 当前运行时",
|
||||
"models.poolSummary": "已启用 {enabled} / {total} 个模型",
|
||||
"models.enableAll": "全部启用",
|
||||
"models.disableAll": "全部禁用",
|
||||
|
||||
// ── 窗口标题栏 ──
|
||||
"titlebar.minimize": "最小化",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { SettingsShell } from "../../components/execution/SettingsShell";
|
||||
import { WorkspaceCreator } from "../../components/execution/WorkspaceCreator";
|
||||
import { WorkspaceInspector } from "../../components/execution/WorkspaceInspector";
|
||||
import { WorkspaceList } from "../../components/execution/WorkspaceList";
|
||||
import { OAuthPromptDialog } from "../../components/settings/dialogs/OAuthPromptDialog";
|
||||
import { SocialAccountsPanel } from "../../components/social/SocialPanel";
|
||||
import { AutomationPanel } from "../../components/automation/AutomationPanel";
|
||||
import { CommunicationHub } from "../../components/communication/CommunicationHub";
|
||||
@@ -269,15 +268,12 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
) : null}
|
||||
|
||||
{section === "settings" && currentCompany ? (
|
||||
<>
|
||||
<SettingsShell
|
||||
snapshot={snapshot}
|
||||
secrets={companySecrets}
|
||||
companyId={currentCompany.id}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
<OAuthPromptDialog />
|
||||
</>
|
||||
<SettingsShell
|
||||
snapshot={snapshot}
|
||||
secrets={companySecrets}
|
||||
companyId={currentCompany.id}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user