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:
ZacharyZhang-NY committed 2026-04-18 21:38:11 -04:00
1 parent ff5e22092a
commit c83d670050
16 files changed
+352 -60

No files matched your search

+7
View File
@@ -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>
)
})}
+2 -2
View File
@@ -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>
);
});
+1 -1
View File
@@ -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" />
) : (
+3
View File
@@ -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",
+3
View File
@@ -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}
</>
);