diff --git a/docs/architecture-audit-2026-07-30/SetupReadinessImplementation.md b/docs/architecture-audit-2026-07-30/SetupReadinessImplementation.md new file mode 100644 index 000000000..0d0def0b8 --- /dev/null +++ b/docs/architecture-audit-2026-07-30/SetupReadinessImplementation.md @@ -0,0 +1,165 @@ +# Architecture Audit — Setup Readiness Implementation + +**Date:** 2026-07-30 +**Scope:** goal-driven setup FSM, secret-free tool detection, Cloud membership, +repo policy, history import, sync verification, destination routing, native +setup reentry accelerator +**Mode:** implementation audit + +## Acceptance criteria + +- [x] Goal-specific visible steps and guards replace slide-index completion. +- [x] Progress is resumable and normalized through one versioned schema. +- [x] Completion persists progress and outcome in one settings write. +- [x] Key detection cannot retain secret-bearing RPC fields. +- [x] Cloud create/join has one shared membership command boundary. +- [x] Organization success requires authoritative roster convergence. +- [x] Admin and member team paths have distinct mutation permissions. +- [x] Workspace, Project, Work Item, Session, and Org remain separate concepts. +- [x] Dismissed/completed users can reopen the same checklist. +- [x] Personal, work-management, and team goals land on existing product + surfaces. + +## Layer 1 — Compilation correctness + +- `npm run typecheck`: passed. +- ESLint over every changed TypeScript/TSX file: passed. +- Focused Vitest suite: 26 tests passed across flow guards, secret-safe + detection, hidden test reentry, route reentry, Settings reentry, outcome + migration, and the atomic settings commit boundary. +- Native menu changes are checked through the `system_services` crate; no + network or persistence wire schema changed. + +## Layer 2 — Dead code and structural deduplication + +- `useCloudOrgMembershipActions` is called by both onboarding and + `CreateCollabOrgView`; create/join auth refresh, alias creation, and roster + convergence no longer have parallel implementations. +- Existing Key validation, external-history rescan, workspace remote resolver, + appearance settings, Cloud policy clients, sync engine, tutorial registry, + Chat Panel tab factories, and sidebar scope atoms remain authoritative. +- Legacy presentation-only onboarding steps remain exported for now; removing + them is a separate cleanup because other imports/tests may still reference + them. They are no longer in the production `STEP_CONFIGS` execution path. + +## Layer 3 — Naming consistency + +| Name | Meaning | +| -------------------------- | -------------------------------------------------------- | +| `SetupWalkthroughProgress` | Durable, secret-free decisions and confirmed results | +| `SetupOperation` | One foreground mutation/detection command | +| `selectedOrgId` | Managed Cloud organization membership selected for setup | +| `repoScopes` | Normalized Git remote identities, never local paths | +| `verifiedAt` | The current team-path postcondition completed | + +The app retains the existing `SetupWalkthrough` route/component name for +compatibility; user-facing copy calls it a setup checklist/readiness flow. + +## Layer 4 — Semantic overloading + +The implementation does not merge domain objects to simplify the wizard: + +| Term | Product meaning in the flow | +| ------------ | ---------------------------------------------- | +| Workspace | Local folders/files used by an agent | +| Session | Agent conversation and execution context | +| Work Item | Trackable task with status/assignee/discussion | +| Project | Planning container for Work Items | +| Organization | Cloud membership and team policy boundary | + +The dedicated work-model step exposes these distinctions before the user lands +in Work Management. + +## Layer 5 — Default branch analysis + +- No goal is selected by default; the first transition is guarded. +- Personal and work-management goals omit Cloud steps rather than silently + pretending team readiness. +- Sharing defaults to `metadata_only`, retaining an explicit privacy boundary. +- A local folder with no Git remote is rejected as a team scope. +- Tool detection failures resolve per provider and do not fail the complete + scan; the UI still reports “not found” rather than claiming success. +- Future/unknown persisted progress fails schema parsing and resets to the + version-1 initial state. + +## Layer 6 — Cross-domain concept leakage + +`useSetupWalkthroughController` is an application-layer orchestrator. It reads +domain state and invokes domain-owned commands; it does not implement +credential parsing, Git remote normalization, Cloud membership, policy RPCs, +sync traversal, appearance persistence, or Chat Panel tab construction. + +The only setup-owned persisted data is readiness metadata. No domain credential +or remote session payload is copied into setup state. + +## Layer 7 — New developer confusion test + +- Step visibility is pure (`getVisibleSetupStepIds`). +- Transition guards are pure (`canCompleteSetupStep`). +- Navigation reachability is pure (`canNavigateToSetupStep`). +- Secret stripping is named and independently tested + (`sanitizeDetectedTool`). +- Foreground operations have explicit names and one active-operation gate. +- The acceptance matrix lives next to the feature in `TEST_CASES.md`. + +## Layer 8 — Wire protocol and serialization + +- The detection RPC remains secret-bearing, but `sanitizeDetectedTool` + constructs a new allow-listed summary synchronously; tests prove API key, + session token, and environment values do not survive. +- `createCloudInvite` retains the existing plaintext-on-device/hash-on-wire + contract. +- Repo scopes sent to `cloud_set_org_repo_scopes` come only from + `resolveShareableScopeKeys`. +- Sharing floor uses the existing typed `CollabSessionAccessMode`. +- Repo scopes and sharing floor are two existing server RPCs, not one + transaction. The UI reports success only after both finish; if the second + fails, the step stays incomplete and is safely retryable. No false + cross-RPC atomicity is claimed. +- No live payload capture was added; existing client schema tests remain the + wire-format gate. + +## Layer 9 — Init parity across entry points + +| Entry point | Shared path | +| ----------------------------------- | ----------------------------------------------- | +| First Release run | Setup route → readiness FSM | +| Settings “Setup checklist” | Same route and persisted FSM | +| DOM shortcut fallback | Atomic setup-only reset → same route and FSM | +| macOS/Linux native app menu | `menu-reopen-setup` → same reset and route | +| Windows custom/native menu | `menu-reopen-setup` → same reset and route | +| Existing organization selection | Same `Org2CloudOrg` roster and namespaced scope | +| Create organization from setup | Shared membership hook | +| Create organization from regular UI | Shared membership hook | +| Join via pasted link/code | Shared membership hook and parser | + +OS deep-link acceptance remains owned by the existing Cloud deep-link handler; +it converges on the same roster atom, which setup reads when reopened. + +## Layer 10 — Resolver symmetry + +All step decisions resolve in the same order: + +1. normalize persisted progress; +2. derive visible steps from goal; +3. normalize current step against the visible set; +4. enforce the current step’s postcondition; +5. persist the next progress snapshot; +6. on completion, atomically persist terminal outcome plus final progress. + +Cloud membership similarly resolves auth refresh → mutation → roster +postcondition → local selection. A superseded/expired session cannot commit a +false organization result. + +## Verification + +- `rustfmt --edition 2021 --check src-tauri/crates/system-services/src/app_menu.rs`: passed. +- `cargo check -p system_services`: passed. + +## Architecture verdict + +All 10 layers were covered. The implementation removes the disconnected +slide-deck architecture and introduces one small orchestration layer over +existing domain owners. Remaining risks are explicit: Cloud admin policy spans +two server RPCs, sync-engine drain does not provide a server receipt count, and +the live team path depends on the existing cloud/dual-instance E2E suites. diff --git a/docs/architecture-audit-2026-07-31/SetupWalkthroughCleanup.md b/docs/architecture-audit-2026-07-31/SetupWalkthroughCleanup.md new file mode 100644 index 000000000..e2beaf3a4 --- /dev/null +++ b/docs/architecture-audit-2026-07-31/SetupWalkthroughCleanup.md @@ -0,0 +1,79 @@ +# Architecture Audit — Setup Walkthrough Cleanup + +**Scope:** readiness onboarding entry points, step registry, WizardSystem +navigation boundary, and legacy setup export chain + +**Date:** 2026-07-31 + +**Auditor:** Codex + +## Acceptance criteria + +- Production setup imports only the active readiness-flow step implementation. +- Current onboarding behavior and persisted flow state remain unchanged. +- Active/completed/locked navigation state has one shared presentation owner. +- No removed legacy symbol remains reachable through a direct or barrel import. +- TypeScript, focused behavior tests, ESLint, and systematic reference sweeps + pass. + +## Entry point and ownership trace + +`SetupWalkthrough/index.tsx` is the production setup surface. Its visible steps +come from `STEP_CONFIGS` in `flow.ts`; every configured renderer is implemented +by `steps/ReadinessSteps.tsx`. The controller remains the authoritative owner of +current step, completed step ids, navigation guards, persistence, and terminal +outcome. `WizardStepNavigation` receives a projection of that state and owns +presentation only. + +The deleted files were reachable only from the obsolete `steps/index.ts` and +`components/index.ts` re-export chains. Neither barrel was imported by the +production flow after the active `SetupOperationError` import was changed to +its concrete readiness module. + +## Deleted legacy chain + +| Symbol/file | Producing path | Verdict | +| ------------------------------------- | ------------------------------------------------------ | ---------------------------------- | +| `AnimatedTitle`, `AnimatedTitleProps` | `components/AnimatedTitle.tsx` → `components/index.ts` | Delete; no active caller | +| `CompleteStep` | `steps/CompleteStep.tsx` → `steps/index.ts` | Delete; absent from `STEP_CONFIGS` | +| `DevPassportStep` | `steps/DevPassportStep.tsx` → `steps/index.ts` | Delete; absent from `STEP_CONFIGS` | +| `GitHubStep` | `steps/GitHubStep.tsx` → `steps/index.ts` | Delete; absent from `STEP_CONFIGS` | +| `RepoStep` | `steps/RepoStep.tsx` → `steps/index.ts` | Delete; absent from `STEP_CONFIGS` | +| `ThemeSelectionStep` | `steps/ThemeSelectionStep.tsx` → `steps/index.ts` | Delete; absent from `STEP_CONFIGS` | +| `WelcomeStep` | `steps/WelcomeStep.tsx` → `steps/index.ts` | Delete; absent from `STEP_CONFIGS` | +| `AGENT_CODE_NAMES` | `constants.ts` | Delete; no active caller | + +## Layer review + +| Layer | Coverage | Result | +| -------------------------------- | -------- | ------------------------------------------------------------------------------------------- | +| 1. Compilation and imports | Covered | Direct readiness import resolves; removed barrels have no consumers. | +| 2. Structure and dead code | Covered | Obsolete step/component chains deleted; shared navigation removes duplicated structure. | +| 3. Types and naming | Covered | Removed orphan `AnimatedTitleProps`; generic navigation item/props preserve step-id typing. | +| 4. Domain ownership | Covered | Controller/flow retain setup state and guards; the primitive owns no domain state. | +| 5. State transitions | Covered | Existing `goToStep` and `canNavigateToSetupStep` paths are reused without new transitions. | +| 6. Persistence | Covered | No schema or writer changed; setup progress writes remain in the controller/settings path. | +| 7. Error/async boundaries | Covered | Busy state disables navigation; async selection remains delegated to the owner. | +| 8. Wire protocol | Skipped | No RPC, serialization, or protocol change. | +| 9. Initialization parity | Skipped | No new initialization path or default. | +| 10. Resolver/runtime integration | Skipped | No resolver, worker, or backend integration change. | + +## Systematic sweeps + +- Removed-symbol search covers all of `src` and returns no references. +- Production imports of `SetupWalkthrough/steps` and + `SetupWalkthrough/components` barrels return no references. +- Raw interactive elements under `src/modules/SetupWalkthrough` return no + matches; native interaction now lives at the shared component boundary. +- Arbitrary `text-[Npx]` classes in SetupWalkthrough and edited WizardSystem + primitives return no matches. + +## Verification and remaining risk + +Focused static-render tests cover navigation current/completed/locked/busy +states and the shared semantic description primitive. Existing flow and i18n +tests cover the unchanged state machine and localized content. TypeScript and +ESLint cover import/export integrity. + +Remaining risk is limited to visual density at uncommon viewport/font-scale +combinations; no data, persistence, protocol, or backend risk was introduced. diff --git a/docs/architecture-audit-2026-08-02/OnboardingReadinessFlow.md b/docs/architecture-audit-2026-08-02/OnboardingReadinessFlow.md new file mode 100644 index 000000000..5ec5d9d8d --- /dev/null +++ b/docs/architecture-audit-2026-08-02/OnboardingReadinessFlow.md @@ -0,0 +1,43 @@ +# Architecture Audit — Onboarding Readiness Flow + +**Date:** 2026-08-02 +**Scope:** first-run preferences, persisted one-time handoff, sidebar guide completion, cloud invite success boundary + +## Verdict + +Pass. The flow has one persisted owner (`general.setupWalkthroughProgress`), uses canonical product facts where they exist, and records only the two education actions that have no durable domain equivalent. No UI shadow state or background lifecycle was introduced. + +## Layer review + +| Layer | Coverage | Verdict | Evidence | +| --- | --- | --- | --- | +| 1. Compile / type integrity | Full | pass | `pnpm typecheck`; typed enum-backed handoff and milestone values. | +| 2. Dead code / parallel paths | Full | pass | Removed the three presentation variants and duplicate walkthrough sidebar; advanced theme remains in canonical Settings. | +| 3. State machine / ownership | Full | pass | `idle → pending → shown`; completion transitions are idempotent and persisted through a functional Settings atom. | +| 4. Semantic overload | Full | pass | Session and organization are derived facts; invite and Team Inbox actions use explicitly named education milestones. | +| 5. Catch-all state / types | Full | pass | No boolean catch-all or string-pattern branching; schema enums constrain every new state. | +| 6. Cross-domain boundaries | Full | pass | Progress helpers live under `store/settings`; cloud invite code no longer imports onboarding UI and writes only after `createCloudInvite` succeeds. | +| 7. Naming / API surface | Full | pass | `requestSetupGuideHandoff`, `consumeSetupGuideHandoff`, and `completeSetupGuideMilestone` describe transition intent. | +| 8. Wire protocol / persistence compatibility | Full | pass | No RPC shape changed; Zod defaults normalize valid legacy progress with `idle` and `[]`. | +| 9. Initialization parity | Reviewed, not changed | pass | No new init entry point; completed setup and hidden test-entry converge through the same stored progress schema. | +| 10. Resolver symmetry | Not applicable | pass | No multi-source resolver or fallback chain was added. | + +## State and edge-case matrix + +| Condition | Behavior | +| --- | --- | +| Finish succeeds | Preferences completion and pending handoff persist in one batch, then Workstation opens. | +| Finish persistence fails | User remains on setup; no completed outcome or handoff is published. | +| Guide opens with pending handoff | Panel opens and persists `shown`. | +| Handoff persistence fails | Panel remains usable; stored `pending` can retry on a later mount. | +| User skipped setup | No handoff is armed. | +| Existing completed user lacks new fields | Defaults to `idle`; no surprise auto-open. | +| Invite API fails | Invite milestone remains incomplete. | +| Invite succeeds but education persistence fails | Invite remains successful; guide milestone can remain incomplete without corrupting domain truth. | +| Repeated completion action | Functional update returns the same object and avoids a disk write. | + +## Verification + +- 38 focused Vitest assertions across setup, locale shape, preference interaction, guide progress, and panel actions. +- TypeScript typecheck and focused ESLint pass. +- Real Tauri flow verified: centered three-row setup → Workstation → one-time four-row guide; existing session/org facts produced `2/4` as expected. diff --git a/docs/frontend-ui-audit-2026-07-30/SetupReadinessFlow.md b/docs/frontend-ui-audit-2026-07-30/SetupReadinessFlow.md new file mode 100644 index 000000000..281353252 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-30/SetupReadinessFlow.md @@ -0,0 +1,80 @@ +# Frontend UI Audit — Setup Readiness Flow + +**Files:** `src/modules/SetupWalkthrough/index.tsx`, +`src/modules/SetupWalkthrough/steps/ReadinessSteps.tsx`, +`src/modules/shared/layouts/OnboardingLayout/index.tsx`, +`src/components/ActionCard/index.tsx`, +`src/components/ActionCard/types.ts`, +`src/scaffold/WizardSystem/primitives/SelectionGrid.tsx`, +`src/scaffold/Tutorials/TutorialsModal.tsx`, +`src/scaffold/Tutorials/GeneralLayoutTour.tsx`, +`src/scaffold/Tutorials/CodeEditorTour.tsx` +**Date:** 2026-07-30 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line / element | Element | Verdict | Reason | Suggested change | +| -------------------------------------------- | ------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `SetupWalkthrough/index.tsx` step navigation | Native ` + ) : null; + + const inlineContent = ( <>
{showCheckbox && !showRadio && ( @@ -147,22 +186,12 @@ const ActionCard: React.FC = ({ )} {showRadio && } - {iconElement ? ( -
- {iconElement} -
- ) : Icon ? ( - - ) : null} + {leadingIcon}

{title}

- {badge && ( - - {badge} - - )} + {badgeElement}
{description && (

{description}

@@ -171,28 +200,8 @@ const ActionCard: React.FC = ({ {tooltip && !isSelected && } - {hasButton && ( - - )} - - {showTrailingCheck && - (tooltip ? ( - - - - - - ) : ( - - ))} + {actionButton} + {trailingCheck} {showArrow && ( = ({ ); + const stackedContent = ( +
+
+
+ {(showCheckbox || showRadio) && ( + <> + {showCheckbox && !showRadio && ( + + )} + {showRadio && } + + )} + {leadingIcon && ( + + {leadingIcon} + + )} +
+ +
+ {badgeElement} + {tooltip && !isSelected && } + {trailingCheck} + {showArrow && ( + + )} +
+
+ +
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+ + {actionButton &&
{actionButton}
} +
+ ); + + const content = layout === "stacked" ? stackedContent : inlineContent; + // A clickable card is an interactive control, not a generic div. Native // button semantics make every wizard/selection surface keyboard reachable // (Tab + Enter/Space) and expose the control to assistive technology. Cards @@ -211,7 +274,11 @@ const ActionCard: React.FC = ({ // invalid nested buttons; only that explicit action performs the callback. if (hasButton) { return ( -
+
{content}
); @@ -227,6 +294,7 @@ const ActionCard: React.FC = ({ onClick={handleCardClick} disabled={disabled} aria-pressed={hasSelector ? selected : undefined} + data-action-card-layout={layout} data-testid={dataTestId} > {content} @@ -236,4 +304,8 @@ const ActionCard: React.FC = ({ export default ActionCard; export { SELECTION_CARD_CLASSES, getSelectionCardClass } from "./config"; -export type { ActionCardProps, ActionCardVariant } from "./types"; +export type { + ActionCardLayout, + ActionCardProps, + ActionCardVariant, +} from "./types"; diff --git a/src/components/ActionCard/types.ts b/src/components/ActionCard/types.ts index 4771a2363..01da6126f 100644 --- a/src/components/ActionCard/types.ts +++ b/src/components/ActionCard/types.ts @@ -5,6 +5,7 @@ import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; export type ActionCardVariant = "default" | "primary" | "secondary" | "subtle"; +export type ActionCardLayout = "inline" | "stacked"; export interface ActionCardProps { /** @@ -28,6 +29,13 @@ export interface ActionCardProps { */ variant?: ActionCardVariant; + /** + * Content arrangement. Stacked keeps badges and selection affordances out of + * the title row for wider choice cards. + * @default 'inline' + */ + layout?: ActionCardLayout; + /** * Icon component (Lucide icon). * For custom icons (e.g. ModelIcon), use iconElement instead. diff --git a/src/components/AppLogo/index.tsx b/src/components/AppLogo/index.tsx new file mode 100644 index 000000000..b5dd9d304 --- /dev/null +++ b/src/components/AppLogo/index.tsx @@ -0,0 +1,35 @@ +import { memo } from "react"; + +import { classNames } from "@src/util/ui/classNames"; + +import appLogoUrl from "../../../public/logo.png"; + +export interface AppLogoProps { + size?: number; + className?: string; + alt?: string; +} + +/** + * Canonical ORGII application logo. + * + * Importing the existing application asset through webpack keeps onboarding, + * packaged builds, and the desktop icon on the same visual identity. + */ +const AppLogo = memo( + ({ size = 32, className, alt = "ORGII" }) => ( + {alt} + ) +); + +AppLogo.displayName = "AppLogo"; + +export default AppLogo; diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/Dropdown/DropdownItem.tsx index 1fa705486..353b07100 100644 --- a/src/components/Dropdown/DropdownItem.tsx +++ b/src/components/Dropdown/DropdownItem.tsx @@ -111,6 +111,43 @@ export interface DropdownItemProps { * Additional style */ style?: React.CSSProperties; + + /** + * ARIA role for the row. Defaults to "option" for listbox-style dropdowns. + * Use "menuitem" for action/command menus (context menus, header menus). + * @default "option" + */ + role?: React.AriaRole; + + /** + * Full-width action-row layout (w-full, left-aligned, single line). Use for + * command/action menu rows that previously used a raw `
+ + + )} + + {activeSection === PREVIEW_SECTION.TEAM_INBOX && ( + +
+ + + + {teamInboxLabel} +
+
+ A} + label={t("navigation:sidebar.guide.inviteTeammate", { + defaultValue: "Invite a teammate", + })} + meta={t("common:status.pending", { + defaultValue: "Pending", + })} + /> + O} + label={t("navigation:sidebar.guide.viewTeamActivity", { + defaultValue: "View team activity", + })} + meta={t("common:status.completed", { + defaultValue: "Completed", + })} + /> +
+
+ )} + + {activeSection === PREVIEW_SECTION.WORK_ITEMS && ( + +
+ + + + {workItemsLabel} +
+
+ + } + label={t("common:status.inProgress", { + defaultValue: "In progress", + })} + meta={sdeLabel} + /> + + } + label={t("common:status.completed", { + defaultValue: "Completed", + })} + meta={workItemsLabel} + /> +
+
+ )} + + + {fileContentOpen && ( + + )} +
+
+ + ); +}); + +SetupApplicationPreview.displayName = "SetupApplicationPreview"; + +export default SetupApplicationPreview; diff --git a/src/modules/SetupWalkthrough/components/SetupPreferencesPanel.tsx b/src/modules/SetupWalkthrough/components/SetupPreferencesPanel.tsx new file mode 100644 index 000000000..5f548919a --- /dev/null +++ b/src/modules/SetupWalkthrough/components/SetupPreferencesPanel.tsx @@ -0,0 +1,134 @@ +import { ArrowRight } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import LanguageSelector from "@src/components/LanguageSelector"; +import Select from "@src/components/Select"; +import type { PrimaryColorPreset } from "@src/config/appearance/primaryColors"; +import { useAppearanceState } from "@src/modules/MainApp/Settings/sections/useAppearanceState"; +import { + SectionContainer, + SectionRow, +} from "@src/modules/shared/layouts/SectionLayout"; +import { DETAIL_PANEL_TOKENS } from "@src/modules/shared/layouts/blocks"; +import { WizardStepContent } from "@src/scaffold/WizardSystem/primitives"; + +import { SETUP_WALKTHROUGH_LAYOUT_TOKENS } from "../layoutTokens"; +import { BasicsStepIcon } from "./SetupStepIcons"; + +interface SetupPreferencesPanelProps { + isClosing: boolean; + onComplete: () => void; + onSkip: () => void; +} + +/** + * Linear-style first-run preferences. Every control writes through the same + * canonical Settings hooks as the full settings page; advanced theme-preset + * selection stays in Settings rather than becoming a first-launch decision. + */ +const SetupPreferencesPanel: React.FC = ({ + isClosing, + onComplete, + onSkip, +}) => { + const { t } = useTranslation(["onboarding", "settings"]); + const { + appearanceMode, + appearanceModeOptions, + handleAppearanceModeChange, + primaryColorOptions, + primaryColorPreset, + setPrimaryColorPreset, + } = useAppearanceState(); + + const languageLabel = t("settings:general.language"); + const appearanceLabel = t("settings:general.appearanceMode"); + const colorLabel = t("settings:general.primaryColor"); + return ( + + + + + + + + setPrimaryColorPreset(String(value) as PrimaryColorPreset) + } + options={primaryColorOptions} + className={SETUP_WALKTHROUGH_LAYOUT_TOKENS.preferenceControl} + size="large" + variant="ghost" + ariaLabel={colorLabel} + dataTestId="setup-primary-color" + /> + + + +
+ + +
+
+ ); +}; + +export default SetupPreferencesPanel; diff --git a/src/modules/SetupWalkthrough/components/SetupStepIcons.tsx b/src/modules/SetupWalkthrough/components/SetupStepIcons.tsx new file mode 100644 index 000000000..aef23fcc1 --- /dev/null +++ b/src/modules/SetupWalkthrough/components/SetupStepIcons.tsx @@ -0,0 +1,52 @@ +import { SlidersHorizontal } from "lucide-react"; + +import workModelIcon from "@src/assets/fileTypeIcons/flow.svg"; +import organizationIcon from "@src/assets/fileTypeIcons/folder-cluster.svg"; +import tutorialIcon from "@src/assets/fileTypeIcons/folder-docs.svg"; +import sharingIcon from "@src/assets/fileTypeIcons/folder-review.svg"; +import goalIcon from "@src/assets/fileTypeIcons/folder-target.svg"; +import themeIcon from "@src/assets/fileTypeIcons/folder-theme.svg"; +import languageIcon from "@src/assets/fileTypeIcons/i18n.svg"; +import toolsIcon from "@src/assets/fileTypeIcons/key.svg"; +import appearanceIcon from "@src/assets/fileTypeIcons/moon.svg"; +import readyIcon from "@src/assets/fileTypeIcons/rocket.svg"; +import { createRepositoryAssetIcon } from "@src/components/RepositoryAssetIcon"; + +export const GoalStepIcon = createRepositoryAssetIcon(goalIcon, "GoalStepIcon"); +export const ToolsStepIcon = createRepositoryAssetIcon( + toolsIcon, + "ToolsStepIcon" +); +export const OrganizationStepIcon = createRepositoryAssetIcon( + organizationIcon, + "OrganizationStepIcon" +); +export const SharingStepIcon = createRepositoryAssetIcon( + sharingIcon, + "SharingStepIcon" +); +export const BasicsStepIcon = SlidersHorizontal; +export const TutorialStepIcon = createRepositoryAssetIcon( + tutorialIcon, + "TutorialStepIcon" +); +export const WorkModelStepIcon = createRepositoryAssetIcon( + workModelIcon, + "WorkModelStepIcon" +); +export const ReadyStepIcon = createRepositoryAssetIcon( + readyIcon, + "ReadyStepIcon" +); +export const LanguagePreferenceIcon = createRepositoryAssetIcon( + languageIcon, + "LanguagePreferenceIcon" +); +export const AppearancePreferenceIcon = createRepositoryAssetIcon( + appearanceIcon, + "AppearancePreferenceIcon" +); +export const ThemePreferenceIcon = createRepositoryAssetIcon( + themeIcon, + "ThemePreferenceIcon" +); diff --git a/src/modules/SetupWalkthrough/components/SetupWalkthroughSidebar.tsx b/src/modules/SetupWalkthrough/components/SetupWalkthroughSidebar.tsx new file mode 100644 index 000000000..d6c023e04 --- /dev/null +++ b/src/modules/SetupWalkthrough/components/SetupWalkthroughSidebar.tsx @@ -0,0 +1,80 @@ +import React, { memo } from "react"; + +import setupMascot from "@src/assets/onboarding/org2-pearl-relay-mascot.png"; +import AppLogo from "@src/components/AppLogo"; + +import { SETUP_WALKTHROUGH_LAYOUT_TOKENS } from "../layoutTokens"; +import { + SETUP_WALKTHROUGH_PRESENTATION, + type SetupWalkthroughPresentation, +} from "../presentation"; +import SetupApplicationPreview from "./SetupApplicationPreview"; + +export interface SetupWalkthroughSidebarProps { + title: React.ReactNode; + description: string; + presentation: SetupWalkthroughPresentation; +} + +/** Preview host; presentation changes never replace the settings column. */ +const SetupWalkthroughSidebar: React.FC = memo( + ({ title, description, presentation }) => { + const showMascot = presentation === SETUP_WALKTHROUGH_PRESENTATION.MASCOT; + + return ( +
+
+
+ + + ORGII + +
+ +
+

+ {title} +

+

+ {description} +

+
+ +
+ {showMascot ? ( +
+
+ +
+ ) : ( +
+ +
+ )} +
+
+
+ ); + } +); + +SetupWalkthroughSidebar.displayName = "SetupWalkthroughSidebar"; + +export default SetupWalkthroughSidebar; diff --git a/src/modules/SetupWalkthrough/components/__tests__/SetupApplicationPreview.interaction.test.ts b/src/modules/SetupWalkthrough/components/__tests__/SetupApplicationPreview.interaction.test.ts new file mode 100644 index 000000000..20e98e9bc --- /dev/null +++ b/src/modules/SetupWalkthrough/components/__tests__/SetupApplicationPreview.interaction.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vitest"; + +import i18n from "@src/i18n"; + +import SetupApplicationPreview from "../SetupApplicationPreview"; + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +describe("SetupApplicationPreview", () => { + let container: HTMLDivElement; + let root: Root; + + const preview = () => + React.createElement( + I18nextProvider, + { i18n }, + React.createElement(SetupApplicationPreview) + ); + + const renderPreview = async () => { + await act(async () => { + root.render(preview()); + }); + }; + + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await renderPreview(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("switches the local preview surface and can return to SDE Agent", () => { + const sdeTab = container.querySelector( + '[data-testid="setup-preview-tab-sde"]' + ); + const teamInboxTab = container.querySelector( + '[data-testid="setup-preview-tab-team-inbox"]' + ); + const workItemsTab = container.querySelector( + '[data-testid="setup-preview-tab-work-items"]' + ); + + expect(sdeTab?.getAttribute("aria-selected")).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-panel-sde"]') + ).not.toBeNull(); + + act(() => teamInboxTab?.click()); + expect(teamInboxTab?.getAttribute("aria-selected")).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-panel-team-inbox"]') + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="setup-preview-panel-sde"]') + ).toBeNull(); + + act(() => workItemsTab?.click()); + expect(workItemsTab?.getAttribute("aria-selected")).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-panel-work-items"]') + ).not.toBeNull(); + + act(() => sdeTab?.click()); + expect(sdeTab?.getAttribute("aria-selected")).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-composer"]') + ).not.toBeNull(); + }); + + it("shows fully readable code beside the active preview surface", () => { + const filesToggle = container.querySelector( + '[data-testid="setup-preview-files-toggle"]' + ); + const teamInboxTab = container.querySelector( + '[data-testid="setup-preview-tab-team-inbox"]' + ); + + act(() => teamInboxTab?.click()); + + expect(filesToggle?.closest("header")).not.toBeNull(); + expect(filesToggle?.getAttribute("aria-expanded")).toBe("false"); + expect( + container.querySelector('[data-testid="setup-preview-code-panel"]') + ).toBeNull(); + + act(() => filesToggle?.click()); + expect(filesToggle?.getAttribute("aria-expanded")).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-content-area"]') + ?.className + ).toContain("grid-cols-2"); + expect( + container.querySelector('[data-testid="setup-preview-code-panel"]') + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="setup-preview-workspace"]') + ?.className + ).toContain("overflow-hidden"); + expect( + container.querySelector('[data-testid="setup-preview-code-editor"]') + ?.textContent + ).toContain('agent = Agent("SDE")'); + expect( + container.querySelector('[data-testid="setup-preview-code-editor"]') + ?.textContent + ).toContain('agent.run("build")'); + expect( + container.querySelector('[data-testid="setup-preview-code-editor"]') + ?.children + ).toHaveLength(8); + expect( + container + .querySelector('[data-testid="setup-preview-tab-team-inbox"]') + ?.getAttribute("aria-selected") + ).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-panel-team-inbox"]') + ).not.toBeNull(); + + act(() => filesToggle?.click()); + expect(filesToggle?.getAttribute("aria-expanded")).toBe("false"); + expect( + container.querySelector('[data-testid="setup-preview-code-panel"]') + ).toBeNull(); + expect( + container + .querySelector('[data-testid="setup-preview-tab-team-inbox"]') + ?.getAttribute("aria-selected") + ).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-panel-team-inbox"]') + ).not.toBeNull(); + }); + + it("shows the localized icon label in a small hover tooltip", async () => { + const teamInboxTab = container.querySelector( + '[data-testid="setup-preview-tab-team-inbox"]' + ); + const label = teamInboxTab?.getAttribute("aria-label"); + + expect(label).toBeTruthy(); + + await act(async () => { + teamInboxTab?.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }) + ); + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(document.querySelector(".native-tooltip-content")?.textContent).toBe( + label + ); + }); + + it("returns to SDE Agent after the preview remounts", async () => { + const teamInboxTab = container.querySelector( + '[data-testid="setup-preview-tab-team-inbox"]' + ); + const filesToggle = container.querySelector( + '[data-testid="setup-preview-files-toggle"]' + ); + act(() => teamInboxTab?.click()); + act(() => filesToggle?.click()); + + expect( + container.querySelector('[data-testid="setup-preview-code-panel"]') + ).not.toBeNull(); + + await act(async () => { + root.render(null); + }); + await renderPreview(); + + expect( + container + .querySelector('[data-testid="setup-preview-tab-sde"]') + ?.getAttribute("aria-selected") + ).toBe("true"); + expect( + container.querySelector('[data-testid="setup-preview-panel-sde"]') + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="setup-preview-code-panel"]') + ).toBeNull(); + expect( + container + .querySelector('[data-testid="setup-preview-files-toggle"]') + ?.getAttribute("aria-expanded") + ).toBe("false"); + }); +}); diff --git a/src/modules/SetupWalkthrough/components/__tests__/SetupPreferencesPanel.interaction.test.ts b/src/modules/SetupWalkthrough/components/__tests__/SetupPreferencesPanel.interaction.test.ts new file mode 100644 index 000000000..20479eb86 --- /dev/null +++ b/src/modules/SetupWalkthrough/components/__tests__/SetupPreferencesPanel.interaction.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import SetupPreferencesPanel from "../SetupPreferencesPanel"; + +const mocks = vi.hoisted(() => ({ + handleAppearanceModeChange: vi.fn(), + setPrimaryColorPreset: vi.fn(), +})); + +interface MockSelectOption { + label: string; + value: string; +} + +interface MockSelectProps { + value: string; + options: MockSelectOption[]; + onChange: (value: string) => void; + dataTestId?: string; + disabled?: boolean; +} + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/modules/MainApp/Settings/sections/useAppearanceState", () => ({ + useAppearanceState: () => ({ + appearanceMode: "dark", + appearanceModeOptions: [ + { label: "Dark", value: "dark" }, + { label: "Light", value: "light" }, + ], + handleAppearanceModeChange: mocks.handleAppearanceModeChange, + primaryColorOptions: [ + { label: "Blue", value: "blue" }, + { label: "Orange", value: "orange" }, + ], + primaryColorPreset: "blue", + setPrimaryColorPreset: mocks.setPrimaryColorPreset, + }), +})); + +vi.mock("@src/components/LanguageSelector", () => ({ + default: ({ ariaLabel }: { ariaLabel?: string }) => + React.createElement("div", { + "aria-label": ariaLabel, + "data-testid": "setup-language", + }), +})); + +vi.mock("@src/components/Select", () => ({ + default: ({ + value, + options, + onChange, + dataTestId, + disabled, + }: MockSelectProps) => + React.createElement( + "select", + { + value, + disabled, + "data-testid": dataTestId, + onChange: (event: React.ChangeEvent) => + onChange(event.currentTarget.value), + }, + options.map((option) => + React.createElement( + "option", + { key: option.value, value: option.value }, + option.label + ) + ) + ), +})); + +describe("SetupPreferencesPanel interactions", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("writes essential preferences through canonical callbacks and finishes", async () => { + const onComplete = vi.fn(); + + await act(async () => { + root.render( + React.createElement(SetupPreferencesPanel, { + isClosing: false, + onComplete, + onSkip: vi.fn(), + }) + ); + }); + + const appearance = container.querySelector( + '[data-testid="setup-appearance-mode"]' + ); + const color = container.querySelector( + '[data-testid="setup-primary-color"]' + ); + act(() => { + if (!appearance) return; + appearance.value = "light"; + appearance.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(mocks.handleAppearanceModeChange).toHaveBeenCalledWith("light"); + + act(() => { + if (!color) return; + color.value = "orange"; + color.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(mocks.setPrimaryColorPreset).toHaveBeenCalledWith("orange"); + + act(() => { + container + .querySelector('[data-testid="setup-finish"]') + ?.click(); + }); + expect(onComplete).toHaveBeenCalledOnce(); + expect( + container.querySelector('[data-testid="setup-presentation"]') + ).toBeNull(); + expect(container.querySelector('[data-testid="setup-theme"]')).toBeNull(); + }); +}); diff --git a/src/modules/SetupWalkthrough/components/__tests__/SetupPreferencesPanel.test.ts b/src/modules/SetupWalkthrough/components/__tests__/SetupPreferencesPanel.test.ts new file mode 100644 index 000000000..78621ff37 --- /dev/null +++ b/src/modules/SetupWalkthrough/components/__tests__/SetupPreferencesPanel.test.ts @@ -0,0 +1,109 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import SetupPreferencesPanel from "../SetupPreferencesPanel"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/modules/MainApp/Settings/sections/useAppearanceState", () => ({ + useAppearanceState: () => ({ + appearanceMode: "dark", + appearanceModeOptions: [{ label: "Dark", value: "dark" }], + globalThemeId: "orgii-dark", + handleAppearanceModeChange: vi.fn(), + handleThemeChange: vi.fn(), + primaryColorOptions: [{ label: "Blue", value: "blue" }], + primaryColorPreset: "blue", + setPrimaryColorPreset: vi.fn(), + themeOptions: [{ label: "ORGII Dark", value: "orgii-dark" }], + }), +})); + +vi.mock("@src/components/LanguageSelector", () => ({ + default: ({ ariaLabel }: { ariaLabel?: string }) => + React.createElement("div", { + "aria-label": ariaLabel, + "data-testid": "setup-language", + }), +})); + +describe("SetupPreferencesPanel", () => { + const getFinishButtonMarkup = (html: string): string => { + const match = html.match( + /]*data-testid="setup-finish")[\s\S]*?<\/button>/ + ); + expect(match).not.toBeNull(); + return match?.[0] ?? ""; + }; + + it("renders only the three essential preference controls and terminal actions", () => { + const html = renderToStaticMarkup( + React.createElement(SetupPreferencesPanel, { + isClosing: false, + onComplete: vi.fn(), + onSkip: vi.fn(), + }) + ); + + expect(html).toContain('data-testid="setup-language"'); + expect(html).toContain('data-testid="setup-appearance-mode"'); + expect(html).toContain('data-testid="setup-primary-color"'); + expect(html).not.toContain('data-testid="setup-theme"'); + expect(html).not.toContain('data-testid="setup-presentation"'); + expect(html).not.toContain("onboarding:readiness.presentation.compact"); + expect(html.match(/role="combobox"/g)).toHaveLength(2); + expect(html.match(/aria-haspopup="listbox"/g)).toHaveLength(2); + expect(html.match(/class="section-layout-row/g)).toHaveLength(3); + expect(html).toContain("select-ghost"); + expect(html).toContain('data-testid="setup-finish"'); + expect(html).toContain('data-testid="setup-skip"'); + }); + + it("keeps terminal actions visible and disables them while closing", () => { + const html = renderToStaticMarkup( + React.createElement(SetupPreferencesPanel, { + isClosing: true, + onComplete: vi.fn(), + onSkip: vi.fn(), + }) + ); + + expect(html).toContain('data-testid="setup-finish"'); + expect(html).toContain('data-testid="setup-skip"'); + expect(html.match(/disabled=""/g)).toHaveLength(2); + expect(html).not.toContain('data-testid="setup-presentation"'); + }); + + it("replaces the fixed-width trailing arrow with an equal-width spinner", () => { + const render = (isClosing: boolean) => + getFinishButtonMarkup( + renderToStaticMarkup( + React.createElement(SetupPreferencesPanel, { + isClosing, + onComplete: vi.fn(), + onSkip: vi.fn(), + }) + ) + ); + + const idle = render(false); + const closing = render(true); + const iconSlotPattern = + /]*)>/; + const idleSlot = idle.match(iconSlotPattern); + const closingSlot = closing.match(iconSlotPattern); + + expect(idleSlot?.[1]).toBe(closingSlot?.[1]); + expect(idleSlot?.[2]).toContain('width="16"'); + expect(closingSlot?.[2]).toContain('width="16"'); + expect(idle).toContain("lucide-arrow-right"); + expect(idle).not.toContain("animate-spin"); + expect(closing).toContain("animate-spin"); + expect(idle.match(/ ({ + default: ({ children }: { children: React.ReactNode }) => children, +})); + +describe("SetupWalkthroughSidebar", () => { + const sharedProps = { + title: React.createElement( + React.Fragment, + null, + "Let's set up your ", + React.createElement("span", null, "ORGII") + ), + description: "A few quick choices to personalize your workspace.", + }; + + it("renders the compact app preview without wizard progress", () => { + const html = renderToStaticMarkup( + React.createElement( + I18nextProvider, + { i18n }, + React.createElement(SetupWalkthroughSidebar, { + ...sharedProps, + presentation: SETUP_WALKTHROUGH_PRESENTATION.COMPACT, + }) + ) + ); + + expect(html).toContain("Let's set up your"); + expect(html).toContain("personalize your workspace"); + expect(html).toContain("logo.png"); + expect(html).toContain('data-testid="setup-compact-preview"'); + expect(html).toContain('data-testid="setup-application-preview"'); + expect(html).toContain('data-testid="setup-preview-composer"'); + expect(html).toContain('data-testid="setup-preview-submit"'); + expect(html).toContain('data-testid="setup-preview-tab-sde"'); + expect(html).toContain('data-testid="setup-preview-tab-team-inbox"'); + expect(html).toContain('data-testid="setup-preview-tab-work-items"'); + expect(html).toContain('data-testid="setup-preview-panel-sde"'); + expect(html).toContain('data-testid="setup-preview-files-toggle"'); + expect(html).toContain("SDE Agent"); + expect(html).not.toContain('data-testid="setup-preview-code-panel"'); + expect(html).not.toContain('data-testid="setup-preview-code-editor"'); + expect(html).not.toContain("package.json"); + expect(html).not.toContain("org2-pearl-relay-mascot.png"); + expect(html).toContain('aria-labelledby="setup-hero-title"'); + expect(html).not.toContain('role="progressbar"'); + }); + + it("replaces only the preview visual with the mascot variant", () => { + const html = renderToStaticMarkup( + React.createElement( + I18nextProvider, + { i18n }, + React.createElement(SetupWalkthroughSidebar, { + ...sharedProps, + presentation: SETUP_WALKTHROUGH_PRESENTATION.MASCOT, + }) + ) + ); + + expect(html).toContain('data-testid="setup-mascot-preview"'); + expect(html).toContain("org2-pearl-relay-mascot.png"); + expect(html).not.toContain('data-testid="setup-application-preview"'); + expect(html).not.toContain('data-testid="setup-compact-preview"'); + }); +}); diff --git a/src/modules/SetupWalkthrough/components/index.ts b/src/modules/SetupWalkthrough/components/index.ts deleted file mode 100644 index e52cdb8e5..000000000 --- a/src/modules/SetupWalkthrough/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * SetupWalkthrough shared components - */ -export { AnimatedTitle } from "./AnimatedTitle"; diff --git a/src/modules/SetupWalkthrough/config.tsx b/src/modules/SetupWalkthrough/config.tsx index 1a6490518..8efe2fb82 100644 --- a/src/modules/SetupWalkthrough/config.tsx +++ b/src/modules/SetupWalkthrough/config.tsx @@ -2,22 +2,25 @@ * SetupWalkthrough step configuration */ import { - FolderGit2, - Github, - IdCard, - Palette, - Rocket, - Sparkles, -} from "lucide-react"; - + BasicsStepIcon, + GoalStepIcon, + OrganizationStepIcon, + ReadyStepIcon, + SharingStepIcon, + ToolsStepIcon, + TutorialStepIcon, + WorkModelStepIcon, +} from "./components/SetupStepIcons"; import { - CompleteStep, - DevPassportStep, - GitHubStep, - RepoStep, - ThemeSelectionStep, - WelcomeStep, -} from "./steps"; + BasicsStep, + GoalStep, + OrganizationStep, + ReadyStep, + SharingStep, + ToolsStep, + TutorialStep, + WorkModelStep, +} from "./steps/ReadinessSteps"; import type { StepConfig } from "./types"; // ============================================ @@ -26,39 +29,51 @@ import type { StepConfig } from "./types"; export const STEP_CONFIGS: StepConfig[] = [ { - id: "welcome", - i18nKey: "welcome", - icon: Sparkles, - content: , + id: "goal", + i18nKey: "goal", + icon: GoalStepIcon, + component: GoalStep, + }, + { + id: "tools", + i18nKey: "tools", + icon: ToolsStepIcon, + component: ToolsStep, + }, + { + id: "organization", + i18nKey: "organization", + icon: OrganizationStepIcon, + component: OrganizationStep, }, { - id: "theme", - i18nKey: "theme", - icon: Palette, - content: , + id: "sharing", + i18nKey: "sharing", + icon: SharingStepIcon, + component: SharingStep, }, { - id: "dev-passport", - i18nKey: "devPassport", - icon: IdCard, - content: , + id: "basics", + i18nKey: "basics", + icon: BasicsStepIcon, + component: BasicsStep, }, { - id: "github", - i18nKey: "github", - icon: Github, - content: , + id: "tutorial", + i18nKey: "tutorial", + icon: TutorialStepIcon, + component: TutorialStep, }, { - id: "workspace", - i18nKey: "workspace", - icon: FolderGit2, - content: , + id: "work-model", + i18nKey: "workModel", + icon: WorkModelStepIcon, + component: WorkModelStep, }, { - id: "complete", - i18nKey: "complete", - icon: Rocket, - content: , + id: "ready", + i18nKey: "ready", + icon: ReadyStepIcon, + component: ReadyStep, }, ]; diff --git a/src/modules/SetupWalkthrough/constants.ts b/src/modules/SetupWalkthrough/constants.ts deleted file mode 100644 index 2b6684576..000000000 --- a/src/modules/SetupWalkthrough/constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * SetupWalkthrough constants - */ - -// ============================================ -// Agent Code Names -// ============================================ - -/** Code names for random generation in DevPassport step */ -export const AGENT_CODE_NAMES = [ - "Shadow Fox", - "Night Owl", - "Storm Rider", - "Cyber Wolf", - "Ghost Protocol", - "Iron Phoenix", - "Silent Viper", - "Dark Matter", - "Neon Spectre", - "Zero Cool", - "Binary Star", - "Quantum Leap", - "Code Breaker", - "Pixel Phantom", - "Stack Overflow", - "Null Pointer", - "Root Access", - "Kernel Panic", - "Syntax Error", - "Cache Money", -] as const; diff --git a/src/modules/SetupWalkthrough/flow.ts b/src/modules/SetupWalkthrough/flow.ts new file mode 100644 index 000000000..f837836a6 --- /dev/null +++ b/src/modules/SetupWalkthrough/flow.ts @@ -0,0 +1,171 @@ +import type { SetupWalkthroughProgress } from "@src/config/settingsSchema/setupWalkthroughProgress"; + +export const SETUP_STEP_IDS = [ + "goal", + "tools", + "organization", + "sharing", + "basics", + "tutorial", + "work-model", + "ready", +] as const; + +export type SetupStepId = (typeof SETUP_STEP_IDS)[number]; + +export interface SetupOrganizationSelection { + orgId: string; + name: string; + role: string; + repoScopes: string[]; + sharingFloor: SetupWalkthroughProgress["sharingFloor"]; +} + +export interface SetupTeamPolicySnapshot { + selectedOrgId: string; + repoScopes: string[]; + sharingFloor: SetupWalkthroughProgress["sharingFloor"]; +} + +/** + * Selecting the same org is a harmless roster refresh and must not erase a + * user's in-progress policy draft. A different org is a privacy boundary: + * hydrate its known server mirrors and clear verification/invite state. + */ +export function applySetupOrganizationSelection( + progress: SetupWalkthroughProgress, + selection: SetupOrganizationSelection +): SetupWalkthroughProgress { + if (progress.selectedOrgId === selection.orgId) { + return { + ...progress, + selectedOrgName: selection.name, + selectedOrgRole: selection.role, + }; + } + return { + ...progress, + selectedOrgId: selection.orgId, + selectedOrgName: selection.name, + selectedOrgRole: selection.role, + repoScopes: selection.repoScopes, + sharingFloor: selection.sharingFloor, + inviteLink: null, + verifiedAt: null, + }; +} + +export function captureSetupTeamPolicy( + progress: SetupWalkthroughProgress +): SetupTeamPolicySnapshot | null { + return progress.selectedOrgId + ? { + selectedOrgId: progress.selectedOrgId, + repoScopes: [...progress.repoScopes], + sharingFloor: progress.sharingFloor, + } + : null; +} + +export function setupTeamPolicyMatches( + progress: SetupWalkthroughProgress, + snapshot: SetupTeamPolicySnapshot +): boolean { + return ( + progress.selectedOrgId === snapshot.selectedOrgId && + progress.sharingFloor === snapshot.sharingFloor && + progress.repoScopes.length === snapshot.repoScopes.length && + progress.repoScopes.every( + (scope, index) => scope === snapshot.repoScopes[index] + ) + ); +} + +export function isTeamSetup(progress: SetupWalkthroughProgress): boolean { + return progress.goal === "team_activity"; +} + +export function getVisibleSetupStepIds( + progress: SetupWalkthroughProgress +): SetupStepId[] { + return isTeamSetup(progress) + ? [...SETUP_STEP_IDS] + : SETUP_STEP_IDS.filter( + (step) => step !== "organization" && step !== "sharing" + ); +} + +export function getNormalizedCurrentStep( + progress: SetupWalkthroughProgress +): SetupStepId { + const visible = getVisibleSetupStepIds(progress); + return visible.includes(progress.currentStepId as SetupStepId) + ? (progress.currentStepId as SetupStepId) + : visible[0]; +} + +export function canCompleteSetupStep( + progress: SetupWalkthroughProgress, + stepId: SetupStepId +): boolean { + switch (stepId) { + case "goal": + return progress.goal !== null; + case "organization": + return progress.selectedOrgId !== null; + case "sharing": + // Admin/owner onboarding proves a committed repo policy. Members cannot + // mutate governance, but still explicitly request and drain one sync + // pass before the team path is considered complete. + return progress.selectedOrgRole === "member" + ? progress.selectedOrgId !== null && progress.verifiedAt !== null + : progress.repoScopes.length > 0 && progress.verifiedAt !== null; + default: + return true; + } +} + +export function advanceSetupProgress( + progress: SetupWalkthroughProgress +): SetupWalkthroughProgress { + const current = getNormalizedCurrentStep(progress); + if (!canCompleteSetupStep(progress, current)) return progress; + const visible = getVisibleSetupStepIds(progress); + const index = visible.indexOf(current); + const next = visible[Math.min(index + 1, visible.length - 1)]; + return { + ...progress, + currentStepId: next, + completedStepIds: Array.from( + new Set([...progress.completedStepIds, current]) + ), + }; +} + +export function retreatSetupProgress( + progress: SetupWalkthroughProgress +): SetupWalkthroughProgress { + const visible = getVisibleSetupStepIds(progress); + const current = getNormalizedCurrentStep(progress); + const index = visible.indexOf(current); + return { + ...progress, + currentStepId: visible[Math.max(0, index - 1)], + }; +} + +export function canNavigateToSetupStep( + progress: SetupWalkthroughProgress, + target: SetupStepId +): boolean { + const visible = getVisibleSetupStepIds(progress); + const currentIndex = visible.indexOf(getNormalizedCurrentStep(progress)); + const targetIndex = visible.indexOf(target); + return ( + targetIndex >= 0 && + (targetIndex <= currentIndex || + visible + .slice(0, targetIndex) + .every((step) => progress.completedStepIds.includes(step))) + ); +} diff --git a/src/modules/SetupWalkthrough/index.scss b/src/modules/SetupWalkthrough/index.scss deleted file mode 100644 index b54da05f7..000000000 --- a/src/modules/SetupWalkthrough/index.scss +++ /dev/null @@ -1,28 +0,0 @@ -// ============================================ -// Toolbar hiding when in walkthrough-mode -// ============================================ -body.walkthrough-mode { - // Hide the tab bar entirely - .tab-bar { - display: none !important; - } - - // Hide ALL toolbar sections (covers all toolbar elements) - [data-toolbar-section] { - display: none !important; - } -} - -// ============================================ -// Animations -// ============================================ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/src/modules/SetupWalkthrough/index.tsx b/src/modules/SetupWalkthrough/index.tsx index e47bd20e4..078b4d37b 100644 --- a/src/modules/SetupWalkthrough/index.tsx +++ b/src/modules/SetupWalkthrough/index.tsx @@ -1,186 +1,131 @@ -/** - * Setup Walkthrough Page - * - * A wizard-style onboarding flow for first-time users. - * Also re-enterable from Settings > General. - * - * Renders outside AppShell (no sidebar) for a focused experience. - */ -import { ArrowLeft, ArrowRight, Check } from "lucide-react"; -import React, { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useAtomValue, useSetAtom } from "jotai"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import Button from "@src/components/Button"; -import "@src/components/DevPassport/devpassport.css"; +import AppLogo from "@src/components/AppLogo"; +import Message from "@src/components/Message"; import { ROUTES } from "@src/config/routes"; +import { normalizeSetupWalkthroughProgress } from "@src/config/settingsSchema/setupWalkthroughProgress"; import { CODEMIRROR_STYLE_NONCE } from "@src/features/CodeMirror/config/nonce"; import { OnboardingLayout } from "@src/modules/shared/layouts"; -import { PanelFooter } from "@src/modules/shared/layouts/blocks"; - -import { STEP_CONFIGS } from "./config"; -import "./index.scss"; - -// ============================================ -// Global Styles (injected) -// ============================================ +import { + saveSettingsBatchAtom, + settingsAtom, +} from "@src/store/settings/settingsAtom"; +import { applicationPreviewStyleAtom } from "@src/store/ui/globalPreferencesPanelAtom"; + +import SetupPreferencesPanel from "./components/SetupPreferencesPanel"; +import SetupWalkthroughSidebar from "./components/SetupWalkthroughSidebar"; +import { + SETUP_WALKTHROUGH_HERO_PANEL_STYLE, + SETUP_WALKTHROUGH_LAYOUT_TOKENS, +} from "./layoutTokens"; +import { completePreferenceSetup } from "./preferenceSetup"; +import "./setupWalkthrough.scss"; + +type SetupWalkthroughOutcome = "open" | "completed" | "dismissed"; const WALKTHROUGH_STYLES = ` - body.walkthrough-mode .tab-bar { - display: none !important; - } - body.walkthrough-mode [data-toolbar-section] { - display: none !important; - } - @keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } - } + body.walkthrough-mode .tab-bar { display: none !important; } + body.walkthrough-mode [data-toolbar-section] { display: none !important; } `; -// ============================================ -// Main Component -// ============================================ - const SetupWalkthrough: React.FC = () => { const navigate = useNavigate(); const { t } = useTranslation("onboarding"); - const [currentStepIndex, setCurrentStepIndex] = useState(0); - - // Add/remove body class for hiding tabbar - React.useLayoutEffect(() => { - document.body.classList.add("walkthrough-mode"); - return () => { - document.body.classList.remove("walkthrough-mode"); - }; - }, []); - - const currentStep = STEP_CONFIGS[currentStepIndex]; - const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === STEP_CONFIGS.length - 1; - - const handleNext = useCallback(() => { - if (isLastStep) { - // Mark setup as complete and navigate to WorkStation - localStorage.setItem("setup_walkthrough_completed", "true"); - navigate(ROUTES.workStation.base.path, { replace: true }); - } else { - setCurrentStepIndex((prev) => prev + 1); - } - }, [isLastStep, navigate]); - - const handleBack = useCallback(() => { - if (!isFirstStep) { - setCurrentStepIndex((prev) => prev - 1); - } - }, [isFirstStep]); - - const handleSkip = useCallback(() => { - // Mark setup as complete and navigate to WorkStation - localStorage.setItem("setup_walkthrough_completed", "true"); - navigate(ROUTES.workStation.base.path, { replace: true }); - }, [navigate]); + const saveSettings = useSetAtom(saveSettingsBatchAtom); + const storedProgress = + useAtomValue(settingsAtom)["general.setupWalkthroughProgress"]; + const progress = useMemo( + () => normalizeSetupWalkthroughProgress(storedProgress), + [storedProgress] + ); + const [isClosing, setIsClosing] = useState(false); + const presentation = useAtomValue(applicationPreviewStyleAtom); + const closingRef = useRef(false); + + const closeWalkthrough = useCallback( + async (outcome: Exclude) => { + if (closingRef.current) return; + closingRef.current = true; + setIsClosing(true); + try { + const finalProgress = + outcome === "completed" + ? completePreferenceSetup(progress) + : progress; + await saveSettings({ + "general.setupWalkthroughOutcome": outcome, + "general.setupWalkthroughProgress": finalProgress, + }); + navigate(ROUTES.workStation.base.path, { replace: true }); + } catch { + Message.error(t("common:status.saveFailed")); + } finally { + closingRef.current = false; + setIsClosing(false); + } + }, + [navigate, progress, saveSettings, t] + ); - // Left content: Step navigation - const leftContent = ( -
-
- {STEP_CONFIGS.map((step, index) => { - const StepIcon = step.icon; - const isActive = index === currentStepIndex; - const isCompleted = index < currentStepIndex; + const preferences = ( + void closeWalkthrough("completed")} + onSkip={() => void closeWalkthrough("dismissed")} + /> + ); - return ( - - ); - })} -
-
+ const previewContent = ( + + ), + }} + /> + } + description={t("readiness.hero.description")} + /> ); - // Right content: Step content + footer - const rightContent = ( -
-
- {currentStep.content} + const preferenceContent = ( +
+
+ + + ORGII + +
+
+ {preferences}
- - } - onClick={handleBack} - > - {t("common:actions.back")} - - ) : undefined - } - secondaryActions={ - !isLastStep - ? [{ label: t("navigation.skipSetup"), onClick: handleSkip }] - : undefined - } - primaryAction={{ - label: isLastStep - ? t("navigation.getStarted") - : t("common:actions.continue"), - onClick: handleNext, - icon: isLastStep ? : , - }} - />
); return ( <> - {/* Global styles for walkthrough mode */} - ); diff --git a/src/modules/SetupWalkthrough/layoutTokens.ts b/src/modules/SetupWalkthrough/layoutTokens.ts new file mode 100644 index 000000000..528fc6684 --- /dev/null +++ b/src/modules/SetupWalkthrough/layoutTokens.ts @@ -0,0 +1,92 @@ +import { TYPOGRAPHY } from "@src/config/workstation/tokens"; + +/** + * Feature composition tokens for the full-screen setup surface. + * + * Reusable controls keep their own visual contracts; this object owns only + * the setup shell's responsive composition so spacing, motion, and overrides + * are not rebuilt across JSX and SCSS. + */ +export const SETUP_WALKTHROUGH_LAYOUT_TOKENS = { + shell: + "setup-walkthrough-ambient !flex !items-center !justify-center !overflow-hidden !bg-bg-2 !p-0", + card: "setup-walkthrough-card !mx-auto !h-5/6 !max-h-none !w-full !max-w-6xl !overflow-hidden !rounded-2xl !border !border-solid !border-border-1 !bg-bg-1 !shadow-xl", + heroBrandRow: "flex items-center gap-3", + brandLogo: "rounded-xl", + brandTitle: `${TYPOGRAPHY.statistic} tracking-tight text-text-1`, + sidebar: + "setup-walkthrough-preview-panel !hidden !max-w-none !basis-5/12 !shrink-0 !items-stretch !justify-stretch !p-0 sm:!flex", + sidebarContent: + "relative flex h-full w-full flex-col overflow-hidden sm:z-10", + hero: "relative flex h-full w-full flex-col px-8 pb-0 pt-8 lg:px-10 lg:pt-10", + heroCopy: "relative z-10 mt-10 max-w-lg lg:mt-14", + heroTitle: + "m-0 text-3xl font-semibold leading-tight tracking-tight text-text-1 xl:text-4xl", + heroBrandAccent: "setup-walkthrough-brand-accent", + heroDescription: + "mt-4 max-w-md text-sm leading-6 text-text-2 xl:text-base xl:leading-7", + heroVisual: "relative mt-auto min-h-64 flex-1", + heroPlanet: "setup-walkthrough-planet absolute bottom-0 h-40", + heroMascot: + "setup-walkthrough-mascot absolute bottom-8 left-1/2 h-56 w-auto -translate-x-1/2 object-contain xl:h-64", + appPreviewWrap: "flex h-full items-end pb-8", + main: "setup-walkthrough-main-panel !min-w-0 !p-0", + mainContent: + "relative flex h-full w-full flex-col items-center justify-center overflow-y-auto px-5 py-16 sm:px-8 lg:px-10 xl:px-12", + mobileBrand: `absolute left-5 top-16 flex items-center gap-3 text-text-1 sm:left-10 lg:hidden ${TYPOGRAPHY.secondary}`, + mobileBrandTitle: "font-semibold tracking-tight", + stepFrame: + "animate-fade-in flex w-full justify-center motion-reduce:animate-none", + preferenceContent: "!max-w-none gap-5 [&>div]:gap-4", + preferenceList: + "!rounded-none !border-0 !bg-transparent !px-0 [&>.section-layout-row]:after:!inset-x-0", + preferenceRow: "!min-h-14 !py-2.5", + preferenceControl: "w-full @[480px]:w-56", + choiceGrid: "max-sm:!grid-cols-1", +} as const; + +export const SETUP_APPLICATION_PREVIEW_TOKENS = { + root: "setup-walkthrough-app-preview mx-auto w-full max-w-md select-none overflow-hidden rounded-xl border border-border-1 bg-bg-1 text-xs text-text-1 shadow-lg", + windowBar: + "relative flex h-7 items-center gap-1.5 border-b border-border-1 bg-bg-2 px-3", + windowDot: "h-1.5 w-1.5 rounded-full bg-fill-4", + windowTitle: "absolute left-1/2 -translate-x-1/2 font-medium text-text-3", + body: "flex h-52 min-h-0 bg-bg-1", + navigation: + "flex w-12 shrink-0 flex-col items-center border-r border-border-1 bg-bg-2 py-2", + navigationBrand: "mb-1 flex items-center justify-center text-text-1", + navigationList: + "mt-1 flex flex-col items-center gap-1 border-t border-border-1 pt-1.5", + navigationButton: "!h-7 !w-7 !p-0 !text-text-3", + navigationButtonSelected: "!h-7 !w-7 !bg-primary-1 !p-0 !text-primary-6", + contentArea: "grid min-w-0 flex-1 grid-cols-1 overflow-hidden", + contentAreaSplit: "grid min-w-0 flex-1 grid-cols-2 overflow-hidden", + workspace: "relative flex min-w-0 overflow-hidden flex-col bg-bg-1", + filesToggle: + "!absolute !right-2 !top-0.5 !z-10 !h-6 !w-6 !rounded-md !text-text-3", + workspacePanel: + "animate-fade-in flex min-h-0 flex-1 flex-col items-center justify-center px-5 py-4 motion-reduce:animate-none", + agentHeading: "mb-3 block text-center text-sm font-semibold text-text-1", + composer: "!mx-auto !w-full !max-w-xs !gap-2 !p-2", + composerPrompt: "truncate px-1 py-1 text-left text-text-3", + composerBar: "flex items-center justify-between", + summaryHeading: + "mb-3 flex items-center justify-center gap-2 text-sm text-text-1", + summaryList: "mx-auto flex w-full max-w-xs flex-col gap-1.5", + summaryRow: + "flex min-w-0 items-center gap-2 rounded-lg border border-border-1 bg-bg-2 px-2.5 py-2", + summaryRowText: + "flex min-w-0 flex-1 flex-col text-left [&>strong]:truncate [&>span]:truncate [&>span]:text-text-3", + codePanel: + "animate-fade-in flex min-w-0 overflow-hidden border-l border-border-1 bg-bg-1 motion-reduce:animate-none", + codeEditor: + "flex min-w-0 flex-1 flex-col justify-evenly overflow-hidden py-2 text-left font-mono", + codeLine: + "flex items-start gap-1 whitespace-nowrap px-2 text-text-3 [&>code]:min-w-0 [&>code]:flex-1 [&>code]:text-left [&>span]:w-4 [&>span]:shrink-0 [&>span]:text-right [&>span]:text-text-4", +} as const; + +export const SETUP_WALKTHROUGH_HERO_PANEL_STYLE: React.CSSProperties = { + flex: "0 0 43%", + width: "43%", + maxWidth: "none", +}; diff --git a/src/modules/SetupWalkthrough/preferenceSetup.ts b/src/modules/SetupWalkthrough/preferenceSetup.ts new file mode 100644 index 000000000..9b60e987d --- /dev/null +++ b/src/modules/SetupWalkthrough/preferenceSetup.ts @@ -0,0 +1,20 @@ +import type { SetupWalkthroughProgress } from "@src/config/settingsSchema/setupWalkthroughProgress"; +import { requestSetupGuideHandoff } from "@src/store/settings/setupGuideProgress"; + +export const PREFERENCE_SETUP_COMPLETION_ID = "preferences"; + +/** + * Completion is a single persisted transition. Legacy setup data is retained + * so opening the optional setup surfaces later never loses previous work. + */ +export function completePreferenceSetup( + progress: SetupWalkthroughProgress +): SetupWalkthroughProgress { + return requestSetupGuideHandoff({ + ...progress, + currentStepId: PREFERENCE_SETUP_COMPLETION_ID, + completedStepIds: Array.from( + new Set([...progress.completedStepIds, PREFERENCE_SETUP_COMPLETION_ID]) + ), + }); +} diff --git a/src/modules/SetupWalkthrough/presentation.ts b/src/modules/SetupWalkthrough/presentation.ts new file mode 100644 index 000000000..aaeb445ad --- /dev/null +++ b/src/modules/SetupWalkthrough/presentation.ts @@ -0,0 +1,5 @@ +export { + APPLICATION_PREVIEW_STYLE as SETUP_WALKTHROUGH_PRESENTATION, + type ApplicationPreviewStyle as SetupWalkthroughPresentation, + normalizeApplicationPreviewStyle as normalizeSetupWalkthroughPresentation, +} from "@src/config/appearance/applicationPreviewStyle"; diff --git a/src/modules/SetupWalkthrough/setupCommands.ts b/src/modules/SetupWalkthrough/setupCommands.ts new file mode 100644 index 000000000..5ae145973 --- /dev/null +++ b/src/modules/SetupWalkthrough/setupCommands.ts @@ -0,0 +1,65 @@ +import { + type AutoDetectResult, + type ModelType, + autoDetectKey, +} from "@src/api/services/keyValidation"; +import { + externalHistoryRescanSource, + fetchExternalSourceStats, +} from "@src/api/tauri/externalHistory"; +import type { SetupWalkthroughProgress } from "@src/config/settingsSchema/setupWalkthroughProgress"; +import { loadSessionRoster } from "@src/store/session"; + +export type SetupToolSummary = SetupWalkthroughProgress["tools"][number]; + +const TOOL_TYPES = ["codex", "claude_code", "cursor_cli"] as const; + +/** + * Convert the secret-bearing detection RPC into the only shape onboarding is + * allowed to retain. API keys, tokens, environment values and account + * metadata are discarded in the same synchronous turn. + */ +export function sanitizeDetectedTool( + agentType: SetupToolSummary["agentType"], + result: AutoDetectResult +): SetupToolSummary { + return { + agentType, + found: result.success && result.keys.length > 0, + keyCount: result.keys.length, + validatedCount: result.keys.filter((key) => key.validated === true).length, + }; +} + +export async function detectSetupTools( + detect: (agentType: ModelType) => Promise = autoDetectKey +): Promise { + const settled = await Promise.allSettled( + TOOL_TYPES.map(async (agentType) => + sanitizeDetectedTool(agentType, await detect(agentType)) + ) + ); + return settled.map((result, index) => + result.status === "fulfilled" + ? result.value + : { + agentType: TOOL_TYPES[index], + found: false, + keyCount: 0, + validatedCount: 0, + } + ); +} + +/** + * Explicit, source-scoped Codex import. The shared rescan service coalesces + * concurrent callers; a roster reload happens only when the cache changed. + */ +export async function importCodexHistory(): Promise { + const result = await externalHistoryRescanSource("codex_app"); + if (result.changedSources.length > 0) { + await loadSessionRoster({ forceRefresh: true }); + } + const stats = await fetchExternalSourceStats("codex_app"); + return stats.sessionCount; +} diff --git a/src/modules/SetupWalkthrough/setupWalkthrough.scss b/src/modules/SetupWalkthrough/setupWalkthrough.scss new file mode 100644 index 000000000..f497559c7 --- /dev/null +++ b/src/modules/SetupWalkthrough/setupWalkthrough.scss @@ -0,0 +1,134 @@ +.setup-walkthrough-ambient { + --setup-glow-primary-strong: color-mix( + in srgb, + var(--color-primary-6) 34%, + transparent + ); + --setup-glow-primary-soft: color-mix( + in srgb, + var(--color-primary-5) 22%, + transparent + ); + --setup-mascot-motion: 5s; + background: + radial-gradient( + ellipse 46% 58% at 18% 88%, + var(--setup-glow-primary-strong), + transparent 68% + ), + radial-gradient( + ellipse 42% 54% at 88% 26%, + var(--setup-glow-primary-soft), + transparent 72% + ), + linear-gradient( + 135deg, + color-mix(in srgb, var(--color-bg-2) 94%, var(--color-text-1)), + var(--color-bg-2) + ); +} + +.setup-walkthrough-card { + isolation: isolate; +} + +.setup-walkthrough-main-panel { + background: #f5f8fc; +} + +[data-theme="dark"] .setup-walkthrough-main-panel { + background: color-mix(in srgb, var(--color-bg-1) 92%, var(--color-primary-1)); +} + +.setup-walkthrough-preview-panel { + background: + radial-gradient( + ellipse 78% 62% at 18% 92%, + color-mix(in srgb, var(--color-primary-5) 18%, transparent), + transparent 72% + ), + linear-gradient( + 145deg, + color-mix(in srgb, var(--color-primary-1) 68%, var(--color-bg-1)), + color-mix(in srgb, var(--color-primary-1) 32%, var(--color-bg-1)) + ); +} + +.setup-walkthrough-app-preview { + box-shadow: + 0 18px 42px color-mix(in srgb, var(--color-primary-7) 14%, transparent), + inset 0 1px 0 color-mix(in srgb, var(--color-text-1) 6%, transparent); +} + +.setup-walkthrough-brand-accent { + background: linear-gradient( + 100deg, + var(--color-primary-5), + var(--color-primary-7) + ); + background-clip: text; + color: transparent; + -webkit-background-clip: text; +} + +.setup-walkthrough-planet { + --setup-planet-overflow: clamp(8rem, 12vw, 14rem); + left: calc(var(--setup-planet-overflow) * -1); + right: calc(var(--setup-planet-overflow) * -1); + border-radius: 50% 50% 0 0; + background: + radial-gradient( + ellipse at 58% 0%, + color-mix(in srgb, var(--color-primary-6) 62%, var(--color-bg-1)), + transparent 20% + ), + linear-gradient( + 100deg, + color-mix(in srgb, var(--color-primary-7) 58%, var(--color-bg-1)), + color-mix(in srgb, var(--color-primary-5) 72%, var(--color-bg-1)) + ); + filter: blur(0.5px); + box-shadow: + 0 -18px 60px var(--setup-glow-primary-strong), + 0 -2px 18px color-mix(in srgb, var(--color-primary-5) 62%, transparent); + -webkit-mask-image: linear-gradient( + 90deg, + #000 0%, + #000 62%, + rgb(0 0 0 / 82%) 74%, + transparent 100% + ); + mask-image: linear-gradient( + 90deg, + #000 0%, + #000 62%, + rgb(0 0 0 / 82%) 74%, + transparent 100% + ); + opacity: 0.7; + transform: none; +} + +.setup-walkthrough-mascot { + animation: setup-mascot-float var(--setup-mascot-motion) ease-in-out infinite; + filter: drop-shadow( + 0 18px 24px color-mix(in srgb, var(--color-primary-7) 48%, transparent) + ); + transform-origin: center bottom; +} + +@keyframes setup-mascot-float { + 0%, + 100% { + transform: translateX(-50%) translateY(0); + } + 50% { + transform: translateX(-50%) translateY(-8px); + } +} + +@media (prefers-reduced-motion: reduce) { + .setup-walkthrough-mascot { + animation: none; + } +} diff --git a/src/modules/SetupWalkthrough/steps/CompleteStep.tsx b/src/modules/SetupWalkthrough/steps/CompleteStep.tsx deleted file mode 100644 index 63ec01be0..000000000 --- a/src/modules/SetupWalkthrough/steps/CompleteStep.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Complete Step - * - * Setup complete, ready to start using the app. - */ -import { Sparkles } from "lucide-react"; -import React from "react"; -import { useTranslation } from "react-i18next"; - -import Button from "@src/components/Button"; - -import { AnimatedTitle } from "../components"; - -export const CompleteStep: React.FC = () => { - const { t } = useTranslation("onboarding"); - - return ( - <> - -
- -
- - ); -}; diff --git a/src/modules/SetupWalkthrough/steps/DevPassportStep.tsx b/src/modules/SetupWalkthrough/steps/DevPassportStep.tsx deleted file mode 100644 index c8e11a77d..000000000 --- a/src/modules/SetupWalkthrough/steps/DevPassportStep.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Dev Passport Step - * - * Allows user to create their developer passport with code name and avatar. - */ -import { Dices, Upload } from "lucide-react"; -import React, { useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import Button from "@src/components/Button"; -import { - type PageContent, - PassportDossier, - type UserProfile, -} from "@src/components/DevPassport"; - -import { AnimatedTitle } from "../components"; -import { AGENT_CODE_NAMES } from "../constants"; - -export const DevPassportStep: React.FC = () => { - const { t } = useTranslation("onboarding"); - const [codeName, setCodeName] = useState(""); - const [profileImage, setProfileImage] = useState(null); - const [isGenerated, setIsGenerated] = useState(false); - const [currentSheetIndex, setCurrentSheetIndex] = useState(-1); - const fileInputRef = useRef(null); - - const handleRandomName = () => { - const randomIndex = Math.floor(Math.random() * AGENT_CODE_NAMES.length); - setCodeName(AGENT_CODE_NAMES[randomIndex]); - }; - - const handleImageUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - setProfileImage(reader.result as string); - }; - reader.readAsDataURL(file); - } - }; - - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - const handleGenerate = () => { - setIsGenerated(true); - }; - - const handleFlip = (index: number) => { - setCurrentSheetIndex(index); - }; - - // Generate stable ID number once on mount (lazy initializer runs outside render) - const [idNumber] = useState( - () => `VE-${Math.random().toString(36).substring(2, 8).toUpperCase()}` - ); - - // Generate user profile from form data - const userProfile: UserProfile = { - name: codeName.toUpperCase() || "AGENT", - role: t("devPassport.role"), - memberSince: new Date() - .toLocaleDateString("en-US", { - day: "2-digit", - month: "short", - year: "numeric", - }) - .toUpperCase(), - idNumber, - avatarUrl: profileImage || "", - }; - - const passportPages: PageContent[] = [ - { id: "p0", type: "profile" }, - { id: "p1", type: "stamps", stamps: [] }, - { id: "p2", type: "stamps", stamps: [] }, - { id: "p3", type: "stamps", stamps: [] }, - { id: "p4", type: "stamps", stamps: [] }, - { id: "p5", type: "stamps", stamps: [] }, - ]; - - // Show passport when generated - if (isGenerated) { - return ( - <> - -
- {/* Passport Display Section - uses Dossier animation */} -
-
- -
-
-
- - ); - } - - return ( - <> - - -
- {/* Code Name Section */} -
- -
- setCodeName(event.target.value)} - /> - -
-
- - {/* Profile Image Section */} -
- -
-
- {profileImage ? ( - Profile - ) : ( - - )} -
-
- - {t("devPassport.selectImage")} - - - {t("devPassport.avatarHint")} - -
-
- -
- - {/* Generate Button */} - -
- - ); -}; diff --git a/src/modules/SetupWalkthrough/steps/GitHubStep.tsx b/src/modules/SetupWalkthrough/steps/GitHubStep.tsx deleted file mode 100644 index ca970a5e0..000000000 --- a/src/modules/SetupWalkthrough/steps/GitHubStep.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * GitHub Step - * - * Connect GitHub account for repository access. - */ -import { Github } from "lucide-react"; -import React from "react"; -import { useTranslation } from "react-i18next"; - -import Button from "@src/components/Button"; - -import { AnimatedTitle } from "../components"; - -export const GitHubStep: React.FC = () => { - const { t } = useTranslation("onboarding"); - - return ( - <> - -
- -

{t("github.hint")}

-
- - ); -}; diff --git a/src/modules/SetupWalkthrough/steps/ReadinessSteps.tsx b/src/modules/SetupWalkthrough/steps/ReadinessSteps.tsx new file mode 100644 index 000000000..a24ec9299 --- /dev/null +++ b/src/modules/SetupWalkthrough/steps/ReadinessSteps.tsx @@ -0,0 +1,760 @@ +import { + Boxes, + BriefcaseBusiness, + Building2, + Check, + Clipboard, + Cloud, + Eye, + FolderGit2, + Inbox, + KeyRound, + LayoutDashboard, + Link2, + ListChecks, + MessageSquare, + MonitorCog, + Play, + Plus, + RefreshCw, + ShieldCheck, + User, + Users, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import InlineAlert from "@src/components/InlineAlert"; +import Input from "@src/components/Input"; +import Select from "@src/components/Select"; +import { TYPOGRAPHY } from "@src/config/workstation/tokens"; +import { openOrg2CloudSignIn } from "@src/features/Org2Cloud/useOrg2CloudSignIn"; +import { useAppearanceState } from "@src/modules/MainApp/Settings/sections/useAppearanceState"; +import { + SECTION_ACTION_GAP_CLASSES, + SECTION_CONTROL_STYLE, + SECTION_PATH_TEXT_CLASSES, + SECTION_VALUE_SMALL_SECONDARY_CLASSES, + SECTION_VALUE_TEXT_CLASSES, + SECTION_VALUE_TEXT_SUCCESS_CLASSES, + SectionContainer, + SectionDescription, + SectionRow, +} from "@src/modules/shared/layouts/SectionLayout"; +import { DETAIL_PANEL_TOKENS } from "@src/modules/shared/layouts/blocks"; +import { openWorkspaceSpotlight } from "@src/scaffold/GlobalSpotlight/openSpotlight"; +import { TUTORIALS } from "@src/scaffold/Tutorials/tutorialRegistry"; +import { + SelectionGrid, + type SelectionGridOption, + WizardStepContent, +} from "@src/scaffold/WizardSystem/primitives"; + +import { + BasicsStepIcon, + GoalStepIcon, + OrganizationStepIcon, + ReadyStepIcon, + SharingStepIcon, + ToolsStepIcon, + TutorialStepIcon, + WorkModelStepIcon, +} from "../components/SetupStepIcons"; +import { SETUP_WALKTHROUGH_LAYOUT_TOKENS } from "../layoutTokens"; +import type { SetupWalkthroughController } from "../useSetupWalkthroughController"; + +type StepProps = { controller: SetupWalkthroughController }; + +const CONTROL_STYLE = { width: "100%", maxWidth: "100%" } as const; + +export const GoalStep: React.FC = ({ controller }) => { + const { t } = useTranslation("onboarding"); + const options = useMemo< + SelectionGridOption>[] + >( + () => [ + { + key: "personal", + label: t("readiness.goal.personal.title"), + description: t("readiness.goal.personal.description"), + icon: User, + dataTestId: "setup-goal-personal", + }, + { + key: "team_activity", + label: t("readiness.goal.team.title"), + description: t("readiness.goal.team.description"), + icon: Users, + dataTestId: "setup-goal-team", + }, + { + key: "work_management", + label: t("readiness.goal.work.title"), + description: t("readiness.goal.work.description"), + icon: BriefcaseBusiness, + dataTestId: "setup-goal-work", + }, + ], + [t] + ); + return ( + + + {t("readiness.goal.hint")} + + ); +}; + +const TOOL_LABELS: Record = { + codex: "Codex", + claude_code: "Claude Code", + cursor_cli: "Cursor", +}; + +export const ToolsStep: React.FC = ({ controller }) => { + const { t } = useTranslation("onboarding"); + const byType = new Map( + controller.progress.tools.map((tool) => [tool.agentType, tool]) + ); + const isDetecting = controller.activeOperation === "detect-tools"; + const isImporting = controller.activeOperation === "import-history"; + return ( + + + {["codex", "claude_code", "cursor_cli"].map((agentType) => { + const tool = byType.get( + agentType as "codex" | "claude_code" | "cursor_cli" + ); + return ( + + + {tool?.found && } + {tool + ? tool.found + ? t("readiness.tools.found", { + count: tool.keyCount, + validated: tool.validatedCount, + }) + : t("readiness.tools.notFound") + : t("readiness.tools.notScanned")} + + + ); + })} + +
+ + +
+ {controller.progress.historySessionCount !== null && ( + + {t("readiness.tools.historyImported", { + count: controller.progress.historySessionCount, + })} + + )} + {t("readiness.tools.privacy")} +
+ ); +}; + +export const OrganizationStep: React.FC = ({ controller }) => { + const { t } = useTranslation("onboarding"); + const [mode, setMode] = useState<"create" | "join">("create"); + const [orgName, setOrgName] = useState(""); + const [invite, setInvite] = useState(""); + const openSignIn = React.useCallback(() => { + controller.setOperationError(null); + void openOrg2CloudSignIn().catch((error: unknown) => { + controller.setOperationError( + error instanceof Error ? error.message : String(error) + ); + }); + }, [controller]); + const orgOptions = controller.cloudOrgs.map((org) => ({ + key: org.orgId, + label: org.name, + description: t("readiness.organization.role", { + role: t(`readiness.organization.roles.${org.role.toLowerCase()}`, { + defaultValue: org.role, + }), + }), + icon: Building2, + })); + const selected = controller.progress.selectedOrgId; + return ( + + {!controller.cloudAuth ? ( + } + onClick={openSignIn} + data-testid="setup-cloud-sign-in" + > + {t("readiness.organization.signIn")} + + } + > + {t("readiness.organization.signInHint")} + + ) : ( + <> + {orgOptions.length > 0 && ( + + + { + const org = controller.cloudOrgs.find( + (item) => item.orgId === orgId + ); + if (org) controller.selectOrganization(org); + }} + columns={2} + cardVariant="subtle" + compactCards + className={SETUP_WALKTHROUGH_LAYOUT_TOKENS.choiceGrid} + /> + + + )} + + + + + +
+ + +
+
+
+ + )} + {selected && ( + + {t("readiness.organization.selected", { + org: controller.progress.selectedOrgName, + })} + + )} +
+ ); +}; + +export const SharingStep: React.FC = ({ controller }) => { + const { t } = useTranslation("onboarding"); + const isMember = controller.progress.selectedOrgRole === "member"; + const isSaved = controller.progress.verifiedAt !== null; + return ( + + + +
+ + {controller.workspaceFolders.length + ? controller.workspaceFolders + .map((folder) => folder.name) + .join(", ") + : t("readiness.sharing.noWorkspace")} + + +
+
+ {!isMember && ( + <> + + + + {controller.progress.repoScopes.map((scope) => ( + + {scope} + + ))} + + + + + - - - + setPreviewStyle(normalizeApplicationPreviewStyle(value)) + } + style={SECTION_CONTROL_STYLE} + ariaLabel={label} + dataTestId="global-preview-style" + /> + +
+ ); +}; + +/** Add a component here to extend the global panel without changing its shell. */ +const GLOBAL_PREFERENCES_SECTIONS: GlobalPreferencesSectionDefinition[] = [ + { id: "preview-style", Component: PreviewStyleSection }, +]; + +/** Window-global, route-independent home for lightweight user preferences. */ +const GlobalPreferencesPanel: React.FC = () => { + const { t } = useTranslation("settings"); + const [open, setOpen] = useAtom(globalPreferencesPanelOpenAtom); + + return ( + setOpen(false)} + title={t("general.preferences")} + footer={null} + width={480} + bodyClassName="p-4" + zIndex={10030} + > +
+ {GLOBAL_PREFERENCES_SECTIONS.map(({ id, Component }) => ( + + ))} +
+
+ ); +}; + +export { + closeGlobalPreferencesPanel, + openGlobalPreferencesPanel, + toggleGlobalPreferencesPanel, +} from "./openGlobalPreferencesPanel"; +export default GlobalPreferencesPanel; diff --git a/src/scaffold/GlobalPreferencesPanel/openGlobalPreferencesPanel.ts b/src/scaffold/GlobalPreferencesPanel/openGlobalPreferencesPanel.ts new file mode 100644 index 000000000..af3ea6dfd --- /dev/null +++ b/src/scaffold/GlobalPreferencesPanel/openGlobalPreferencesPanel.ts @@ -0,0 +1,18 @@ +import { globalPreferencesPanelOpenAtom } from "@src/store/ui/globalPreferencesPanelAtom"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; + +export function openGlobalPreferencesPanel(): void { + getInstrumentedStore().set(globalPreferencesPanelOpenAtom, true); +} + +export function closeGlobalPreferencesPanel(): void { + getInstrumentedStore().set(globalPreferencesPanelOpenAtom, false); +} + +export function toggleGlobalPreferencesPanel(): void { + const store = getInstrumentedStore(); + store.set( + globalPreferencesPanelOpenAtom, + !store.get(globalPreferencesPanelOpenAtom) + ); +} diff --git a/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.test.ts b/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.test.ts index c1de26316..e354cd6f8 100644 --- a/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.test.ts +++ b/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.test.ts @@ -18,6 +18,7 @@ import SidebarSettingsMenuButton from "./SidebarSettingsMenuButton"; const mocks = vi.hoisted(() => ({ closeDropdown: vi.fn(), goToSettings: vi.fn(), + navigateTo: vi.fn(), })); vi.mock("react-i18next", () => ({ @@ -27,6 +28,7 @@ vi.mock("react-i18next", () => ({ vi.mock("@src/hooks/navigation", () => ({ useAppNavigation: () => ({ goToSettings: mocks.goToSettings, + navigateTo: mocks.navigateTo, }), })); @@ -108,4 +110,18 @@ describe("SidebarSettingsMenuButton", () => { expect(changelogButton).toBeUndefined(); expect(tutorialButton).toBeDefined(); }); + + it("reopens the setup checklist through shared app navigation", () => { + const setupButton = Array.from( + document.body.querySelectorAll("button") + ).find( + (button) => button.textContent === "sidebar.settingsMenu.setupChecklist" + ); + + expect(setupButton).toBeDefined(); + act(() => setupButton?.click()); + + expect(mocks.navigateTo).toHaveBeenCalledWith("/orgii/app/walkthrough"); + expect(mocks.closeDropdown).toHaveBeenCalled(); + }); }); diff --git a/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.tsx b/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.tsx index 23368b266..b47007a5c 100644 --- a/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.tsx +++ b/src/scaffold/NavigationSidebar/blocks/SidebarSettingsMenuButton.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"; import { ChevronRight, Circle, + ClipboardCheck, Contrast, Gauge, HelpCircle, @@ -32,6 +33,7 @@ import { } from "@src/components/KeyboardShortcut"; import type { AppearanceMode } from "@src/config/appearance/globalThemes"; import { getShortcutKeys } from "@src/config/keyboard/shortcutDisplay"; +import { ROUTES } from "@src/config/routes"; import { useDropdownEngine } from "@src/hooks/dropdown"; import { useAppNavigation } from "@src/hooks/navigation"; import { useAppearanceState } from "@src/modules/MainApp/Settings/sections/useAppearanceState"; @@ -78,7 +80,7 @@ function getSubmenuPosition( const SidebarSettingsMenuButton: React.FC = React.memo(() => { const { t } = useTranslation("navigation"); const { t: tSettings } = useTranslation("settings"); - const { goToSettings } = useAppNavigation(); + const { goToSettings, navigateTo } = useAppNavigation(); const devModeEnabled = useAtomValue(devModeEnabledAtom); const ramPanelRef = useRef(null); const submenuPanelRef = useRef(null); @@ -200,6 +202,11 @@ const SidebarSettingsMenuButton: React.FC = React.memo(() => { closeAll(); }, [closeAll]); + const handleOpenSetupChecklist = useCallback(() => { + closeAll(); + navigateTo(ROUTES.auth.setup.path); + }, [closeAll, navigateTo]); + const handleOpenGuiControl = useCallback(() => { openAgentControlSpotlight(); closeAll(); @@ -320,6 +327,24 @@ const SidebarSettingsMenuButton: React.FC = React.memo(() => { TODO(changelog-web): Restore the Changelog item here, directly above Tutorials, once the maintained web destination is ready. */} +

- {currentStep.title} + {t(`tutorials.codeEditor.steps.${currentStep.id}.title`)}

- {currentStep.body} + {t(`tutorials.codeEditor.steps.${currentStep.id}.body`)}

@@ -428,12 +415,12 @@ const CodeEditorTour: React.FC = ({ open, onClose }) => { iconOnly icon={} disabled={isFirstStep} - aria-label="Previous step" - title="Previous step" + aria-label={t("tutorials.chrome.previous")} + title={t("tutorials.chrome.previous")} onClick={goPrevious} /> - Use ← / → or < / > + {t("tutorials.chrome.keyboardHint")}
diff --git a/src/scaffold/Tutorials/GeneralLayoutTour.tsx b/src/scaffold/Tutorials/GeneralLayoutTour.tsx index 204a4528e..02ae990ed 100644 --- a/src/scaffold/Tutorials/GeneralLayoutTour.tsx +++ b/src/scaffold/Tutorials/GeneralLayoutTour.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; import { getMaterialConfig } from "@src/components/Glass/config"; @@ -22,7 +23,10 @@ import { useCurrentTheme } from "@src/util/ui/theme/themeUtils"; import { getViewportSize } from "@src/util/ui/window/viewport"; import { createAnimationFrameScheduler } from "./animationFrameScheduler"; -import { GENERAL_LAYOUT_TOUR_TARGETS } from "./generalLayoutTourConfig"; +import { + GENERAL_LAYOUT_TOUR_STEPS, + GENERAL_LAYOUT_TOUR_TARGETS, +} from "./generalLayoutTourConfig"; type GeneralLayoutTourTarget = (typeof GENERAL_LAYOUT_TOUR_TARGETS)[keyof typeof GENERAL_LAYOUT_TOUR_TARGETS]; @@ -30,8 +34,6 @@ type GeneralLayoutTourTarget = interface TourStep { id: string; target: GeneralLayoutTourTarget; - title: string; - body: string; /** Snap into My Station when this step becomes active (dock chrome steps). */ switchToMyStation?: boolean; stationMode?: StationMode; @@ -50,56 +52,7 @@ interface GeneralLayoutTourProps { onClose: () => void; } -const TOUR_STEPS: TourStep[] = [ - { - id: "chat-panel", - target: GENERAL_LAYOUT_TOUR_TARGETS.chatPanel, - title: "Chat Panel", - body: "This is where users have conversations with agents, review activity, and send follow-up instructions.", - }, - { - id: "station-mode-pill", - target: GENERAL_LAYOUT_TOUR_TARGETS.stationModePill, - title: "Switch station modes", - stationMode: "my-station", - demoStationModeSwitch: true, - body: "Use this pill to switch station modes. Desktop means My Station, your workspace. Infinity means Agent Station, the agent activity view.", - }, - { - id: "dock", - target: GENERAL_LAYOUT_TOUR_TARGETS.dock, - title: "Agent Station dock chrome", - body: "The dock switches apps inside the station. The tour temporarily disables auto-hide so these controls stay visible.", - }, - { - id: "all-tabs", - target: GENERAL_LAYOUT_TOUR_TARGETS.dockAllTabs, - title: "All Tabs", - switchToMyStation: true, - body: "The first dock icon shows all open tabs together, regardless of which workstation app owns them.", - }, - { - id: "code-editor", - target: GENERAL_LAYOUT_TOUR_TARGETS.dockCodeEditor, - title: "Code Editor", - switchToMyStation: true, - body: "Use Code Editor for files, diffs, terminals, source control, and coding changes made during a session.", - }, - { - id: "browser", - target: GENERAL_LAYOUT_TOUR_TARGETS.dockBrowser, - title: "Browser", - switchToMyStation: true, - body: "Use Browser for web pages, previews, app testing, and browser-based investigation alongside the chat.", - }, - { - id: "projects", - target: GENERAL_LAYOUT_TOUR_TARGETS.dockProjects, - title: "Projects", - switchToMyStation: true, - body: "Use Projects to track work items, plans, and project state connected to the current workspace.", - }, -]; +const TOUR_STEPS: readonly TourStep[] = GENERAL_LAYOUT_TOUR_STEPS; const POPOVER_WIDTH = 320; const VIEWPORT_PADDING = 16; @@ -218,6 +171,7 @@ const GeneralLayoutTour: React.FC = ({ open, onClose, }) => { + const { t } = useTranslation("onboarding"); const { isDark } = useCurrentTheme(); const setStationMode = useSetAtom(stationModeAtom); const [stepIndex, setStepIndex] = useState(0); @@ -396,12 +350,15 @@ const GeneralLayoutTour: React.FC = ({ >
- Step {stepIndex + 1} of {TOUR_STEPS.length} + {t("tutorials.chrome.stepProgress", { + current: stepIndex + 1, + total: TOUR_STEPS.length, + })}

- {currentStep.title} + {t(`tutorials.generalLayout.steps.${currentStep.id}.title`)}

- {currentStep.body} + {t(`tutorials.generalLayout.steps.${currentStep.id}.body`)}

{currentStep.demoStationModeSwitch && ( @@ -422,8 +379,12 @@ const GeneralLayoutTour: React.FC = ({ - Desktop - My Station + + {t("tutorials.chrome.desktop")} + + + {t("tutorials.chrome.myStation")} +
@@ -431,8 +392,12 @@ const GeneralLayoutTour: React.FC = ({ - Infinity - Agent Station + + {t("tutorials.chrome.infinity")} + + + {t("tutorials.chrome.agentStation")} +
@@ -458,12 +423,12 @@ const GeneralLayoutTour: React.FC = ({ iconOnly icon={} disabled={isFirstStep} - aria-label="Previous step" - title="Previous step" + aria-label={t("tutorials.chrome.previous")} + title={t("tutorials.chrome.previous")} onClick={goPrevious} /> - Use ← / → or < / > + {t("tutorials.chrome.keyboardHint")} ))} diff --git a/src/scaffold/Tutorials/__tests__/generalLayoutTourConfig.test.ts b/src/scaffold/Tutorials/__tests__/generalLayoutTourConfig.test.ts new file mode 100644 index 000000000..719cc83c9 --- /dev/null +++ b/src/scaffold/Tutorials/__tests__/generalLayoutTourConfig.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { + GENERAL_LAYOUT_TOUR_STEPS, + GENERAL_LAYOUT_TOUR_TARGETS, +} from "../generalLayoutTourConfig"; + +describe("general layout tour config", () => { + it("introduces Runtime as the second independently targeted step", () => { + expect(GENERAL_LAYOUT_TOUR_STEPS[1]).toEqual({ + id: "runtime", + target: GENERAL_LAYOUT_TOUR_TARGETS.runtimeNavigation, + }); + expect( + GENERAL_LAYOUT_TOUR_STEPS.filter( + (step) => step.target === GENERAL_LAYOUT_TOUR_TARGETS.runtimeNavigation + ) + ).toHaveLength(1); + }); +}); diff --git a/src/scaffold/Tutorials/generalLayoutTourConfig.ts b/src/scaffold/Tutorials/generalLayoutTourConfig.ts index d8e643e6a..14d8bef40 100644 --- a/src/scaffold/Tutorials/generalLayoutTourConfig.ts +++ b/src/scaffold/Tutorials/generalLayoutTourConfig.ts @@ -2,6 +2,7 @@ export const GENERAL_LAYOUT_TOUR_EVENT = "orgii:start-general-layout-tour"; export const GENERAL_LAYOUT_TOUR_TARGETS = { sessionSidebar: "session-layout-session-sidebar", + runtimeNavigation: "session-layout-runtime-navigation", chatPanel: "session-layout-chat-panel", workstation: "session-layout-workstation", stationModePill: "session-layout-station-mode-pill", @@ -11,3 +12,44 @@ export const GENERAL_LAYOUT_TOUR_TARGETS = { dockBrowser: "session-layout-dock-browser", dockProjects: "session-layout-dock-projects", } as const; + +export const GENERAL_LAYOUT_TOUR_STEPS = [ + { + id: "chat-panel", + target: GENERAL_LAYOUT_TOUR_TARGETS.chatPanel, + }, + { + id: "runtime", + target: GENERAL_LAYOUT_TOUR_TARGETS.runtimeNavigation, + }, + { + id: "station-mode-pill", + target: GENERAL_LAYOUT_TOUR_TARGETS.stationModePill, + stationMode: "my-station", + demoStationModeSwitch: true, + }, + { + id: "dock", + target: GENERAL_LAYOUT_TOUR_TARGETS.dock, + }, + { + id: "all-tabs", + target: GENERAL_LAYOUT_TOUR_TARGETS.dockAllTabs, + switchToMyStation: true, + }, + { + id: "code-editor", + target: GENERAL_LAYOUT_TOUR_TARGETS.dockCodeEditor, + switchToMyStation: true, + }, + { + id: "browser", + target: GENERAL_LAYOUT_TOUR_TARGETS.dockBrowser, + switchToMyStation: true, + }, + { + id: "projects", + target: GENERAL_LAYOUT_TOUR_TARGETS.dockProjects, + switchToMyStation: true, + }, +] as const; diff --git a/src/scaffold/Tutorials/guideTargets.ts b/src/scaffold/Tutorials/guideTargets.ts index f8f0c00b8..3142d6cf4 100644 --- a/src/scaffold/Tutorials/guideTargets.ts +++ b/src/scaffold/Tutorials/guideTargets.ts @@ -6,6 +6,10 @@ export const GUIDE_TARGETS = { WORKSTATION_TAB_HEADER: "workstation.tabHeader", WORKSTATION_DOCK: "workstation.dock", CHAT_PANEL: "chatPanel.surface", + COLLAB_ORG_NAME_INPUT: "collabOrg.nameInput", + CLOUD_ORG_INVITE_ACTION: "cloudOrg.inviteAction", + CLOUD_ORG_MEMBERS_SECTION: "cloudOrg.membersSection", + TEAM_RUNTIME_TABS: "teamRuntime.tabs", ADE_MANAGER_COMPOSER: "adeManager.composer", TUTORIALS_MODAL: "tutorials.modal", } as const; diff --git a/src/scaffold/Tutorials/tutorialRegistry.ts b/src/scaffold/Tutorials/tutorialRegistry.ts index 0cf5748f2..5a6dc078b 100644 --- a/src/scaffold/Tutorials/tutorialRegistry.ts +++ b/src/scaffold/Tutorials/tutorialRegistry.ts @@ -7,9 +7,13 @@ export type TutorialId = "general-layout" | "code-editor"; export interface TutorialEntry { id: TutorialId; + /** Stable English metadata exposed to agent actions; UI must use the keys. */ title: string; description: string; durationLabel: string; + titleKey: `tutorials.${"generalLayout" | "codeEditor"}.title`; + descriptionKey: `tutorials.${"generalLayout" | "codeEditor"}.description`; + durationKey: `tutorials.${"generalLayout" | "codeEditor"}.duration`; eventName: string; } @@ -20,6 +24,9 @@ export const TUTORIALS: TutorialEntry[] = [ description: "Learn the Session sidebar, Chat Panel, station mode switcher, Workstation, dock, and app areas.", durationLabel: "1 min", + titleKey: "tutorials.generalLayout.title", + descriptionKey: "tutorials.generalLayout.description", + durationKey: "tutorials.generalLayout.duration", eventName: GENERAL_LAYOUT_TOUR_EVENT, }, { @@ -28,6 +35,9 @@ export const TUTORIALS: TutorialEntry[] = [ description: "Learn tabs, repo and branch switching, Source Control, Git History, and the project dashboard.", durationLabel: "2 min", + titleKey: "tutorials.codeEditor.title", + descriptionKey: "tutorials.codeEditor.description", + durationKey: "tutorials.codeEditor.duration", eventName: CODE_EDITOR_TOUR_EVENT, }, ]; diff --git a/src/scaffold/WizardSystem/index.ts b/src/scaffold/WizardSystem/index.ts index f8d0f3d8a..7fd5433b8 100644 --- a/src/scaffold/WizardSystem/index.ts +++ b/src/scaffold/WizardSystem/index.ts @@ -12,6 +12,10 @@ export { WIZARD_CONTENT_TOKENS, WizardShell, + WizardStepContent, + WIZARD_STEP_CONTENT_TOKENS, + WizardStepNavigation, + WIZARD_STEP_NAVIGATION_TOKENS, WizardStepLayout, FormField, FORM_FIELD_TOKENS, @@ -22,6 +26,11 @@ export { export type { WizardShellProps, + WizardStepContentProps, + WizardStepIcon, + WizardStepIconProps, + WizardStepNavigationItem, + WizardStepNavigationProps, WizardStepLayoutProps, FormFieldProps, SelectionGridProps, diff --git a/src/scaffold/WizardSystem/primitives/FormField.tsx b/src/scaffold/WizardSystem/primitives/FormField.tsx index 56519fe6b..53a22cd45 100644 --- a/src/scaffold/WizardSystem/primitives/FormField.tsx +++ b/src/scaffold/WizardSystem/primitives/FormField.tsx @@ -17,16 +17,18 @@ */ import React from "react"; +import { TYPOGRAPHY } from "@src/config/workstation/tokens"; + // ============================================ // Tokens // ============================================ export const FORM_FIELD_TOKENS = { - label: "text-[12px] font-medium text-text-2", + label: `${TYPOGRAPHY.valueMedium} text-text-2`, labelMargin: "mb-1.5", - hint: "mt-1 text-[11px] text-text-3", - error: "mt-1 text-[12px] text-danger-6", - warning: "mt-1 text-[11px] text-warning-6", + hint: `mt-1 text-text-3 ${TYPOGRAPHY.secondary}`, + error: `mt-1 text-danger-6 ${TYPOGRAPHY.value}`, + warning: `mt-1 text-warning-6 ${TYPOGRAPHY.secondary}`, } as const; // ============================================ diff --git a/src/scaffold/WizardSystem/primitives/SelectionGrid.tsx b/src/scaffold/WizardSystem/primitives/SelectionGrid.tsx index 142d4751a..91e56d204 100644 --- a/src/scaffold/WizardSystem/primitives/SelectionGrid.tsx +++ b/src/scaffold/WizardSystem/primitives/SelectionGrid.tsx @@ -34,10 +34,15 @@ */ import type { LucideIcon } from "lucide-react"; import React from "react"; +import { useTranslation } from "react-i18next"; import ActionCard from "@src/components/ActionCard"; -import type { ActionCardVariant } from "@src/components/ActionCard/types"; +import type { + ActionCardLayout, + ActionCardVariant, +} from "@src/components/ActionCard/types"; import Button from "@src/components/Button"; +import { TYPOGRAPHY } from "@src/config/workstation/tokens"; // ============================================ // Types @@ -80,10 +85,16 @@ interface SharedGridProps { compactLabel?: string; /** ActionCard variant. Use "subtle" for bg-bg-2 cards when placed on fill-2 backgrounds. */ cardVariant?: ActionCardVariant; + /** Arrange each card's content inline or vertically. */ + cardLayout?: ActionCardLayout; + /** Optional class name applied to every card. */ + cardClassName?: string; /** When using showSelect on cards, show the trailing checkmark (default true). */ showSelectionCheck?: boolean; /** Use compact card padding, useful for text-only picker cards. */ compactCards?: boolean; + /** Optional class name for the grid wrapper. */ + className?: string; } /** Single-select mode (default) — radio-style */ @@ -121,6 +132,7 @@ export type SelectionGridProps = function SelectionGrid( props: SelectionGridProps ) { + const { t } = useTranslation("common"); const { options, selected, @@ -129,8 +141,11 @@ function SelectionGrid( compact = false, compactLabel, cardVariant = "default", + cardLayout = "inline", + cardClassName = "", showSelectionCheck = true, compactCards = false, + className = "", } = props; const isMulti = props.multiSelect === true; @@ -144,7 +159,7 @@ function SelectionGrid( return (
- {label} + {label} {nextOption && ( )}
@@ -168,7 +183,7 @@ function SelectionGrid( }; return ( -
+
{options.map((option) => { const isSelected = isMulti ? (selected as Set).has(option.key) @@ -198,7 +213,9 @@ function SelectionGrid( selected={isSelected} disabled={option.disabled} variant={cardVariant} + layout={cardLayout} compact={compactCards} + className={cardClassName} dataTestId={ option.dataTestId ?? `selection-grid-option-${option.key}` } diff --git a/src/scaffold/WizardSystem/primitives/WizardProgressCard.tsx b/src/scaffold/WizardSystem/primitives/WizardProgressCard.tsx index 308395d2a..509afff1b 100644 --- a/src/scaffold/WizardSystem/primitives/WizardProgressCard.tsx +++ b/src/scaffold/WizardSystem/primitives/WizardProgressCard.tsx @@ -18,6 +18,7 @@ import { Loader2 } from "lucide-react"; import React from "react"; import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; +import { TYPOGRAPHY } from "@src/config/workstation/tokens"; import { DETAIL_PANEL_TOKENS } from "@src/modules/shared/layouts/blocks"; export interface WizardProgressCardProps { @@ -42,7 +43,11 @@ const WizardProgressCard: React.FC = ({ size={SPINNER_TOKENS.default} className="shrink-0 animate-spin text-primary-6" /> - {children ?? {message}} + {children ?? ( + + {message} + + )}
); }; diff --git a/src/scaffold/WizardSystem/primitives/WizardStepContent.tsx b/src/scaffold/WizardSystem/primitives/WizardStepContent.tsx new file mode 100644 index 000000000..18e77660c --- /dev/null +++ b/src/scaffold/WizardSystem/primitives/WizardStepContent.tsx @@ -0,0 +1,47 @@ +/** + * WizardStepContent + * + * Wizard adapter for the shared SectionHeading intro treatment. It supplies + * the standard wizard content width and level-one heading contract so wizard + * variants do not rebuild layout or semantic markup locally. + */ +import React, { memo } from "react"; + +import { + SectionHeading, + type SectionHeadingProps, +} from "@src/modules/shared/layouts/SectionLayout"; +import { DETAIL_PANEL_TOKENS } from "@src/modules/shared/layouts/blocks"; + +export const WIZARD_STEP_CONTENT_TOKENS = { + container: DETAIL_PANEL_TOKENS.contentWidth, +} as const; + +export interface WizardStepContentProps { + title: string; + description?: string; + icon?: SectionHeadingProps["icon"]; + children?: React.ReactNode; + className?: string; +} + +const WizardStepContent: React.FC = memo( + ({ title, description, icon: Icon, children, className = "" }) => { + return ( + + {children} + + ); + } +); + +WizardStepContent.displayName = "WizardStepContent"; + +export default WizardStepContent; diff --git a/src/scaffold/WizardSystem/primitives/WizardStepLayout.tsx b/src/scaffold/WizardSystem/primitives/WizardStepLayout.tsx index 697211cc7..d9f6b2ffe 100644 --- a/src/scaffold/WizardSystem/primitives/WizardStepLayout.tsx +++ b/src/scaffold/WizardSystem/primitives/WizardStepLayout.tsx @@ -24,6 +24,7 @@ import React, { useEffect, useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; +import { TYPOGRAPHY } from "@src/config/workstation/tokens"; import { SECTION_GAP_CLASSES } from "@src/modules/shared/layouts/SectionLayout"; import { DETAIL_PANEL_TOKENS, @@ -169,11 +170,13 @@ const WizardStepLayout: React.FC = ({ >
{!hideStepIndicator && ( - + {t("keyVault.stepLabel", { current: currentStep, total: totalSteps, diff --git a/src/scaffold/WizardSystem/primitives/WizardStepNavigation.tsx b/src/scaffold/WizardSystem/primitives/WizardStepNavigation.tsx new file mode 100644 index 000000000..cf3457398 --- /dev/null +++ b/src/scaffold/WizardSystem/primitives/WizardStepNavigation.tsx @@ -0,0 +1,141 @@ +/** + * WizardStepNavigation + * + * Shared vertical step navigation for wizards that expose their full setup + * path. Flow ownership stays with the caller; this component only projects + * active, completed, and locked state into the canonical navigation UI. + */ +import React, { type AriaAttributes, type ComponentType, memo } from "react"; + +import completedIcon from "@src/assets/fileTypeIcons/todo.svg"; +import { createRepositoryAssetIcon } from "@src/components/RepositoryAssetIcon"; +import { HEADER_ICON_SIZE, TYPOGRAPHY } from "@src/config/workstation/tokens"; + +export interface WizardStepIconProps { + size?: number | string; + strokeWidth?: number | string; + className?: string; + "aria-hidden"?: AriaAttributes["aria-hidden"]; +} + +export type WizardStepIcon = ComponentType; + +const CompletedStepIcon = createRepositoryAssetIcon( + completedIcon, + "CompletedStepIcon" +); + +export const WIZARD_STEP_NAVIGATION_TOKENS = { + list: "scrollbar-overlay flex flex-1 flex-col overflow-y-auto", + item: "relative pb-1", + button: + "group flex w-full items-center gap-3 rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors duration-150", + buttonActive: "bg-sidebar-selected", + buttonEnabled: "cursor-pointer bg-transparent hover:bg-fill-2", + buttonDisabled: "cursor-not-allowed bg-transparent opacity-45", + icon: "relative z-10 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border transition-colors", + iconActive: "border-text-1 bg-text-1 text-bg-1", + iconCompleted: "border-border-2 bg-bg-2 text-text-1", + iconPending: "border-border-2 bg-bg-2 text-text-3", + title: `block truncate ${TYPOGRAPHY.contentTitle}`, + description: `mt-0.5 block truncate text-text-3 ${TYPOGRAPHY.contentSubtitle}`, + iconSize: HEADER_ICON_SIZE.sm, +} as const; + +export interface WizardStepNavigationItem { + id: T; + title: string; + description: string; + icon: WizardStepIcon; + completed: boolean; + disabled?: boolean; +} + +export interface WizardStepNavigationProps { + items: WizardStepNavigationItem[]; + activeId: T; + onSelect: (id: T) => void | Promise; + ariaLabel: string; + disabled?: boolean; + className?: string; + testIdPrefix?: string; +} + +function WizardStepNavigationComponent({ + items, + activeId, + onSelect, + ariaLabel, + disabled = false, + className = "", + testIdPrefix = "wizard-step", +}: WizardStepNavigationProps) { + return ( + + ); +} + +const WizardStepNavigation = memo( + WizardStepNavigationComponent +) as typeof WizardStepNavigationComponent; + +export default WizardStepNavigation; diff --git a/src/scaffold/WizardSystem/primitives/__tests__/TEST_CASES.md b/src/scaffold/WizardSystem/primitives/__tests__/TEST_CASES.md new file mode 100644 index 000000000..bd23484e0 --- /dev/null +++ b/src/scaffold/WizardSystem/primitives/__tests__/TEST_CASES.md @@ -0,0 +1,15 @@ +# WizardSystem primitive acceptance cases + +| Primitive | Case | Expected result | +| ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | +| `WizardStepNavigation` | Active step | Exposes `aria-current="step"` without inserting selection markup that changes row geometry. | +| `WizardStepNavigation` | Completed step | Replaces the step glyph inside the existing fixed-size icon slot and preserves title alignment. | +| `WizardStepNavigation` | Locked step | Uses a native disabled button and does not call the owning flow. | +| `WizardStepNavigation` | Owning flow is busy | Disables every step while preserving the active state. | +| `WizardStepContent` | Intro content | Delegates semantic title, description, icon, spacing, and accessible heading linkage to `SectionHeading`. | + +## Verification + +- Static render: `WizardStepNavigation.test.ts`, `WizardStepContent.test.ts`. +- Consumer flow: `src/modules/SetupWalkthrough/__tests__/flow.test.ts`. +- Static gates: TypeScript typecheck and ESLint. diff --git a/src/scaffold/WizardSystem/primitives/__tests__/WizardStepContent.test.ts b/src/scaffold/WizardSystem/primitives/__tests__/WizardStepContent.test.ts new file mode 100644 index 000000000..d9ea6a7a5 --- /dev/null +++ b/src/scaffold/WizardSystem/primitives/__tests__/WizardStepContent.test.ts @@ -0,0 +1,35 @@ +import { Circle } from "lucide-react"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { SECTION_INTRO_TOKENS } from "@src/modules/shared/layouts/SectionLayout"; + +import WizardStepContent, { + WIZARD_STEP_CONTENT_TOKENS, +} from "../WizardStepContent"; + +describe("WizardStepContent", () => { + it("owns the shared wizard heading hierarchy and accessible relationship", () => { + const html = renderToStaticMarkup( + React.createElement( + WizardStepContent, + { + title: "Choose a tutorial", + description: "Learn on the product surface.", + icon: Circle, + }, + React.createElement("div", null, "Step controls") + ) + ); + + expect(html).toContain(" = ({ size = 16 }) => + React.createElement("span", { + "data-testid": "repository-test-icon", + style: { width: size, height: size }, + }); + +describe("WizardStepNavigation", () => { + it("renders active, completed, and locked steps with accessible state", () => { + const html = renderToStaticMarkup( + React.createElement(WizardStepNavigation, { + activeId: "tools", + ariaLabel: "Setup steps", + onSelect: () => undefined, + testIdPrefix: "setup-step", + items: [ + { + id: "goal", + title: "Goal", + description: "Choose an outcome", + icon: TestIcon, + completed: true, + }, + { + id: "tools", + title: "Tools", + description: "Detect local access", + icon: TestIcon, + completed: false, + }, + { + id: "ready", + title: "Ready", + description: "Open the destination", + icon: TestIcon, + completed: false, + disabled: true, + }, + ], + }) + ); + + expect(html).toContain("]*disabled=""[^>]*data-testid="setup-step-ready"/ + ); + expect(html).toContain("Goal"); + expect(html).toContain("Ready"); + }); + + it("disables every step while the owning flow is busy", () => { + const html = renderToStaticMarkup( + React.createElement(WizardStepNavigation, { + activeId: "goal", + ariaLabel: "Setup steps", + disabled: true, + onSelect: () => undefined, + items: [ + { + id: "goal", + title: "Goal", + description: "Choose an outcome", + icon: TestIcon, + completed: false, + }, + { + id: "tools", + title: "Tools", + description: "Detect local access", + icon: TestIcon, + completed: false, + }, + ], + }) + ); + + expect(html.match(/ disabled=""/g)).toHaveLength(2); + }); +}); diff --git a/src/scaffold/WizardSystem/primitives/index.ts b/src/scaffold/WizardSystem/primitives/index.ts index ca7bfadba..170f393da 100644 --- a/src/scaffold/WizardSystem/primitives/index.ts +++ b/src/scaffold/WizardSystem/primitives/index.ts @@ -4,12 +4,13 @@ * Reusable building blocks for multi-step wizard flows. * Used by KeyVaultWizard, ChannelWizard, and future wizards. */ +import { DETAIL_PANEL_TOKENS } from "@src/modules/shared/layouts/blocks"; export const WIZARD_CONTENT_TOKENS = { /** Horizontal content inset (px-4) — matches DETAIL_PANEL_TOKENS.contentPadding */ - paddingClass: "px-4", + paddingClass: DETAIL_PANEL_TOKENS.contentPadding, /** Content bottom padding (pb-2) — reduced when footer follows */ - paddingBottomClass: "pb-2", + paddingBottomClass: DETAIL_PANEL_TOKENS.contentPaddingBottom, } as const; export { default as WizardShell } from "./WizardShell"; @@ -18,6 +19,23 @@ export type { WizardShellProps } from "./WizardShell"; export { default as WizardStepLayout } from "./WizardStepLayout"; export type { WizardStepLayoutProps } from "./WizardStepLayout"; +export { + default as WizardStepContent, + WIZARD_STEP_CONTENT_TOKENS, +} from "./WizardStepContent"; +export type { WizardStepContentProps } from "./WizardStepContent"; + +export { + default as WizardStepNavigation, + WIZARD_STEP_NAVIGATION_TOKENS, +} from "./WizardStepNavigation"; +export type { + WizardStepIcon, + WizardStepIconProps, + WizardStepNavigationItem, + WizardStepNavigationProps, +} from "./WizardStepNavigation"; + export { default as FormField, FORM_FIELD_TOKENS } from "./FormField"; export type { FormFieldProps } from "./FormField"; diff --git a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts index 1ad69ae78..a58151cd8 100644 --- a/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts +++ b/src/store/chatPanel/__tests__/chatPanelTabsAtom.test.ts @@ -70,7 +70,10 @@ async function loadChatPanelTabAtoms() { } = await import("../chatPanelTerminalAtom"); const { activeChatPanelSurfaceAtom, + CHAT_PANEL_COLLAB_ORG_MODE, + CHAT_PANEL_COLLAB_ORG_SOURCE, chatPanelCreateProjectContextAtom, + chatPanelCollabOrgCreateIntentAtom, chatPanelCreateTargetAtom, chatPanelMaximizedAtom, chatPanelNavigateAtom, @@ -94,11 +97,14 @@ async function loadChatPanelTabAtoms() { activeSessionIdAtom, addChatPanelLaunchpadTabAtom, CHAT_PANEL_CREATE_TARGET, + CHAT_PANEL_COLLAB_ORG_MODE, + CHAT_PANEL_COLLAB_ORG_SOURCE, CHAT_PANEL_SURFACE_KIND, chatPanelTabsAtom, chatPanelMaximizedAtom, chatPanelNavigateAtom, chatPanelCreateProjectContextAtom, + chatPanelCollabOrgCreateIntentAtom, chatPanelCreateTargetAtom, chatPanelStartPageOpenAtom, closeChatPanelTabAtom, @@ -904,7 +910,14 @@ describe("ChatPanel navigation tabs", () => { const launchpadTabId = store.get(chatPanelTabsAtom).activeTabId; const managementTabId = store.set(openOrganizationInChatPanelTabAtom, { - organization: { kind: "cloud", cloudOrg: { orgId: "org-a" } }, + organization: { + kind: "cloud", + cloudOrg: { + orgId: "org-a", + initialView: "members", + initialViewRequestId: 1, + }, + }, title: "Manage ORG", }); @@ -917,14 +930,43 @@ describe("ChatPanel navigation tabs", () => { title: "Manage ORG", organization: { kind: "cloud", - cloudOrg: { orgId: "org-a" }, + cloudOrg: { + orgId: "org-a", + initialView: "members", + initialViewRequestId: 1, + }, }, }), ]), }); expect(store.get(activeChatPanelSurfaceAtom)).toEqual({ kind: CHAT_PANEL_SURFACE_KIND.CLOUD_ORG, - cloudOrg: { orgId: "org-a" }, + cloudOrg: { + orgId: "org-a", + initialView: "members", + initialViewRequestId: 1, + }, + }); + + const refocusedTabId = store.set(openOrganizationInChatPanelTabAtom, { + organization: { + kind: "cloud", + cloudOrg: { + orgId: "org-a", + initialView: "members", + initialViewRequestId: 2, + }, + }, + title: "Manage ORG", + }); + expect(refocusedTabId).toBe(managementTabId); + expect(store.get(activeChatPanelSurfaceAtom)).toEqual({ + kind: CHAT_PANEL_SURFACE_KIND.CLOUD_ORG, + cloudOrg: { + orgId: "org-a", + initialView: "members", + initialViewRequestId: 2, + }, }); const switchedTabId = store.set(openOrganizationInChatPanelTabAtom, { @@ -995,7 +1037,10 @@ describe("ChatPanel navigation tabs", () => { it("opens creator targets inside the singleton start page", async () => { const { + CHAT_PANEL_COLLAB_ORG_MODE, + CHAT_PANEL_COLLAB_ORG_SOURCE, CHAT_PANEL_CREATE_TARGET, + chatPanelCollabOrgCreateIntentAtom, chatPanelCreateProjectContextAtom, chatPanelCreateTargetAtom, chatPanelStartPageOpenAtom, @@ -1017,6 +1062,11 @@ describe("ChatPanel navigation tabs", () => { orgId: "org-a", scopeBreadcrumbLabel: "ORG A", }, + collabOrgCreateIntent: { + requestId: 7, + source: CHAT_PANEL_COLLAB_ORG_SOURCE.CLOUD, + mode: CHAT_PANEL_COLLAB_ORG_MODE.CREATE, + }, }); expect(openedTabId).toBe(launchpadTabId); @@ -1028,12 +1078,23 @@ describe("ChatPanel navigation tabs", () => { orgId: "org-a", scopeBreadcrumbLabel: "ORG A", }); + expect(store.get(chatPanelCollabOrgCreateIntentAtom)).toEqual({ + requestId: 7, + source: CHAT_PANEL_COLLAB_ORG_SOURCE.CLOUD, + mode: CHAT_PANEL_COLLAB_ORG_MODE.CREATE, + }); expect(store.get(chatPanelStartPageOpenAtom)).toBe(true); expect( store .get(chatPanelTabsAtom) .tabs.filter((tab) => tab.type === "start-page") ).toHaveLength(1); + + store.set(openCreateTargetInChatPanelStartPageAtom, { + target: CHAT_PANEL_CREATE_TARGET.COLLAB_ORG, + title: "Launchpad", + }); + expect(store.get(chatPanelCollabOrgCreateIntentAtom)).toBeNull(); }); it("keeps the Work Item creator selected after switching back to Launchpad", async () => { diff --git a/src/store/chatPanel/chatPanelTabOpenAtoms.ts b/src/store/chatPanel/chatPanelTabOpenAtoms.ts index e6b505cf8..9dd05fa45 100644 --- a/src/store/chatPanel/chatPanelTabOpenAtoms.ts +++ b/src/store/chatPanel/chatPanelTabOpenAtoms.ts @@ -2,6 +2,7 @@ import { atom } from "jotai"; import { sessionByIdAtom } from "@src/store/session/sessionAtom"; import { + type ChatPanelCollabOrgCreateIntent, type ChatPanelCreateProjectContext, type ChatPanelCreateTarget, type ChatPanelSelectedOrganization, @@ -9,6 +10,7 @@ import { type ChatPanelSelectedWorkItem, type ChatPanelSelectedWorkspace, type WorkspaceOverviewTab, + chatPanelCollabOrgCreateIntentAtom, chatPanelCreateProjectContextAtom, chatPanelCreateTargetAtom, chatPanelStartPageOpenAtom, @@ -88,6 +90,7 @@ interface OpenCreateTargetInStartPageOptions { target: ChatPanelCreateTarget; title?: string; createProjectContext?: ChatPanelCreateProjectContext | null; + collabOrgCreateIntent?: ChatPanelCollabOrgCreateIntent | null; } /** Focus Launchpad and show a creator inside its pinned inner navigation. */ @@ -102,6 +105,10 @@ export const openCreateTargetInChatPanelStartPageAtom = atom( chatPanelCreateProjectContextAtom, options.createProjectContext ?? null ); + set( + chatPanelCollabOrgCreateIntentAtom, + options.collabOrgCreateIntent ?? null + ); set(chatPanelStartPageOpenAtom, true); return tabId; } diff --git a/src/store/settings/settingsAtom.atomic.test.ts b/src/store/settings/settingsAtom.atomic.test.ts new file mode 100644 index 000000000..af1d4e9cf --- /dev/null +++ b/src/store/settings/settingsAtom.atomic.test.ts @@ -0,0 +1,88 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { saveSettingsBatchAtom, settingsAtom } from "./settingsAtom"; + +const { rpcCallMock } = vi.hoisted(() => ({ + rpcCallMock: vi.fn(), +})); + +vi.mock("@src/api/tauri/rpc/invoke", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + rpcCall: rpcCallMock, + }; +}); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +async function flushQueuedWrite() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("saveSettingsBatchAtom", () => { + beforeEach(() => { + rpcCallMock.mockReset(); + }); + + it("publishes one matching memory snapshot only after the partial write succeeds", async () => { + const write = deferred(); + rpcCallMock.mockReturnValueOnce(write.promise); + const store = createStore(); + const before = store.get(settingsAtom); + const updates = { + "general.setupWalkthroughOutcome": "completed" as const, + "general.setupWalkthroughProgress": { + ...before["general.setupWalkthroughProgress"], + currentStep: "ready" as const, + completedSteps: [ + "goal", + "tools", + "basics", + "tutorial", + "work-model", + "ready", + ] as const, + }, + }; + + const saving = store.set(saveSettingsBatchAtom, updates); + await flushQueuedWrite(); + + expect(store.get(settingsAtom)).toEqual(before); + expect(rpcCallMock).toHaveBeenCalledTimes(1); + expect(rpcCallMock.mock.calls[0]?.[1]).toEqual({ + partial: updates, + }); + + write.resolve(); + await saving; + + expect(store.get(settingsAtom)).toMatchObject(updates); + }); + + it("leaves memory unchanged when the partial write fails", async () => { + rpcCallMock.mockRejectedValueOnce(new Error("disk unavailable")); + const store = createStore(); + const before = store.get(settingsAtom); + + await expect( + store.set(saveSettingsBatchAtom, { + "general.setupWalkthroughOutcome": "completed", + }) + ).rejects.toThrow("disk unavailable"); + + expect(store.get(settingsAtom)).toEqual(before); + }); +}); diff --git a/src/store/settings/settingsAtom.ts b/src/store/settings/settingsAtom.ts index afa8c54e7..423733e0d 100644 --- a/src/store/settings/settingsAtom.ts +++ b/src/store/settings/settingsAtom.ts @@ -156,6 +156,20 @@ export const saveSettingAtom = atom( ); saveSettingAtom.debugLabel = "saveSettingAtom"; +/** + * Persist several settings as one partial-file write before publishing the + * matching in-memory snapshot. Explicit multi-step flows use this at commit + * points so progress and outcome cannot diverge. + */ +export const saveSettingsBatchAtom = atom( + null, + async (_get, set, updates: Partial) => { + await enqueueSettingsPartialWrite(updates as Record); + set(settingsAtom, (current) => ({ ...current, ...updates })); + } +); +saveSettingsBatchAtom.debugLabel = "saveSettingsBatchAtom"; + /** * Atom to update multiple settings at once. * Useful for batch operations or form submissions. diff --git a/src/store/settings/setupGuideProgress.ts b/src/store/settings/setupGuideProgress.ts new file mode 100644 index 000000000..7387d4d5f --- /dev/null +++ b/src/store/settings/setupGuideProgress.ts @@ -0,0 +1,48 @@ +import { + SETUP_GUIDE_PERSISTED_MILESTONES, + type SetupWalkthroughProgress, +} from "@src/config/settingsSchema/setupWalkthroughProgress"; + +export type SetupGuidePersistedMilestone = + (typeof SETUP_GUIDE_PERSISTED_MILESTONES)[number]; + +export const SETUP_GUIDE_PERSISTED_MILESTONE = { + TEAMMATE_INVITED: "teammate_invited", + PRODUCT_TOUR_STARTED: "product_tour_started", + /** Reused from the original team-activity task for v1 compatibility. */ + TEAM_ACTIVITY_VIEWED: "team_activity_viewed", +} as const satisfies Record; + +/** Arm the one-time handoff only for users who have not seen it before. */ +export function requestSetupGuideHandoff( + progress: SetupWalkthroughProgress +): SetupWalkthroughProgress { + if (progress.guideHandoff !== "idle") return progress; + return { ...progress, guideHandoff: "pending" }; +} + +/** Persist that the pending handoff has been displayed. */ +export function consumeSetupGuideHandoff( + progress: SetupWalkthroughProgress +): SetupWalkthroughProgress { + if (progress.guideHandoff !== "pending") return progress; + return { ...progress, guideHandoff: "shown" }; +} + +export function completeSetupGuideMilestone( + progress: SetupWalkthroughProgress, + milestone: SetupGuidePersistedMilestone +): SetupWalkthroughProgress { + if (progress.guideCompletedMilestones.includes(milestone)) return progress; + return { + ...progress, + guideCompletedMilestones: [...progress.guideCompletedMilestones, milestone], + }; +} + +export function hasCompletedSetupGuideMilestone( + progress: SetupWalkthroughProgress, + milestone: SetupGuidePersistedMilestone +): boolean { + return progress.guideCompletedMilestones.includes(milestone); +} diff --git a/src/store/settings/setupGuideProgressAtom.ts b/src/store/settings/setupGuideProgressAtom.ts new file mode 100644 index 000000000..084e806c1 --- /dev/null +++ b/src/store/settings/setupGuideProgressAtom.ts @@ -0,0 +1,33 @@ +import { atom } from "jotai"; + +import { + type SetupWalkthroughProgress, + normalizeSetupWalkthroughProgress, +} from "@src/config/settingsSchema/setupWalkthroughProgress"; + +import { saveSettingsBatchAtom, settingsAtom } from "./settingsAtom"; + +export type SetupGuideProgressUpdater = ( + progress: SetupWalkthroughProgress +) => SetupWalkthroughProgress; + +/** + * Functional persisted update for education-only setup progress. Callers do + * not retain a second snapshot and no-op updates avoid unnecessary disk I/O. + */ +export const saveSetupGuideProgressAtom = atom( + null, + async (get, set, update: SetupGuideProgressUpdater) => { + const current = normalizeSetupWalkthroughProgress( + get(settingsAtom)["general.setupWalkthroughProgress"] + ); + const next = update(current); + if (next === current) return false; + + await set(saveSettingsBatchAtom, { + "general.setupWalkthroughProgress": next, + }); + return true; + } +); +saveSetupGuideProgressAtom.debugLabel = "saveSetupGuideProgress"; diff --git a/src/store/ui/__tests__/globalPreferencesPanelAtom.test.ts b/src/store/ui/__tests__/globalPreferencesPanelAtom.test.ts new file mode 100644 index 000000000..fe19d5ebc --- /dev/null +++ b/src/store/ui/__tests__/globalPreferencesPanelAtom.test.ts @@ -0,0 +1,28 @@ +import { createStore } from "jotai"; +import { describe, expect, it } from "vitest"; + +import { APPLICATION_PREVIEW_STYLE } from "@src/config/appearance/applicationPreviewStyle"; + +import { applicationPreviewStyleAtom } from "../globalPreferencesPanelAtom"; + +describe("applicationPreviewStyleAtom", () => { + it("stores supported preview styles", () => { + const store = createStore(); + + store.set(applicationPreviewStyleAtom, APPLICATION_PREVIEW_STYLE.MASCOT); + + expect(store.get(applicationPreviewStyleAtom)).toBe( + APPLICATION_PREVIEW_STYLE.MASCOT + ); + }); + + it("recovers unsupported values to compact", () => { + const store = createStore(); + + store.set(applicationPreviewStyleAtom, "unsupported"); + + expect(store.get(applicationPreviewStyleAtom)).toBe( + APPLICATION_PREVIEW_STYLE.COMPACT + ); + }); +}); diff --git a/src/store/ui/chatPanelAtom.ts b/src/store/ui/chatPanelAtom.ts index 922d079da..0e95104b4 100644 --- a/src/store/ui/chatPanelAtom.ts +++ b/src/store/ui/chatPanelAtom.ts @@ -291,6 +291,38 @@ export const chatPanelCreateTargetAtom = atom( ); chatPanelCreateTargetAtom.debugLabel = "chatPanelCreateTargetAtom"; +export const CHAT_PANEL_COLLAB_ORG_SOURCE = { + LOCAL: "local", + CLOUD: "cloud", +} as const; + +export type ChatPanelCollabOrgSource = + (typeof CHAT_PANEL_COLLAB_ORG_SOURCE)[keyof typeof CHAT_PANEL_COLLAB_ORG_SOURCE]; + +export const CHAT_PANEL_COLLAB_ORG_MODE = { + CREATE: "create", + JOIN: "join", +} as const; + +export type ChatPanelCollabOrgMode = + (typeof CHAT_PANEL_COLLAB_ORG_MODE)[keyof typeof CHAT_PANEL_COLLAB_ORG_MODE]; + +/** + * One-shot navigation intent for an explicitly requested Add ORG form state. + * The creator consumes and clears it; authoritative organization state remains + * owned by the local/cloud organization stores. + */ +export interface ChatPanelCollabOrgCreateIntent { + requestId: number; + source: ChatPanelCollabOrgSource; + mode: ChatPanelCollabOrgMode; +} + +export const chatPanelCollabOrgCreateIntentAtom = + atom(null); +chatPanelCollabOrgCreateIntentAtom.debugLabel = + "chatPanelCollabOrgCreateIntentAtom"; + export const chatPanelStartPageOpenAtom = atom(true); chatPanelStartPageOpenAtom.debugLabel = "chatPanelStartPageOpenAtom"; @@ -384,8 +416,20 @@ chatPanelSelectedWorkspaceAtom.debugLabel = "chatPanelSelectedWorkspaceAtom"; */ export interface ChatPanelSelectedCloudOrg { orgId: string; + /** Optional management surface requested by the action opening this ORG. */ + initialView?: CloudOrgManagementView; + /** Changes when an opener explicitly requests `initialView` again. */ + initialViewRequestId?: number; } +export type CloudOrgManagementView = "general" | "sync" | "members"; + +export const CLOUD_ORG_MANAGEMENT_VIEW = { + GENERAL: "general", + SYNC: "sync", + MEMBERS: "members", +} as const satisfies Record; + /** The explicit provider variant owned by the shared organization tab. */ export type ChatPanelSelectedOrganization = | { @@ -518,6 +562,7 @@ function resetChatPanelSurfaceState(set: SetAtom): void { set(chatPanelSelectedCloudOrgAtom, null); set(chatPanelExploreOpenAtom, false); set(chatPanelCreateProjectContextAtom, null); + set(chatPanelCollabOrgCreateIntentAtom, null); set(chatPanelCreateTargetAtom, DEFAULT_CHAT_PANEL_CREATE_TARGET); set(chatPanelWorkspaceOverviewTabAtom, WORKSPACE_OVERVIEW_TAB.OVERVIEW); } diff --git a/src/store/ui/globalPreferencesPanelAtom.ts b/src/store/ui/globalPreferencesPanelAtom.ts new file mode 100644 index 000000000..841ffd3a2 --- /dev/null +++ b/src/store/ui/globalPreferencesPanelAtom.ts @@ -0,0 +1,34 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +import { + APPLICATION_PREVIEW_STYLE, + type ApplicationPreviewStyle, + normalizeApplicationPreviewStyle, +} from "@src/config/appearance/applicationPreviewStyle"; + +const APPLICATION_PREVIEW_STYLE_STORAGE_KEY = "orgii:application-preview-style"; + +export const globalPreferencesPanelOpenAtom = atom(false); +globalPreferencesPanelOpenAtom.debugLabel = "globalPreferencesPanelOpenAtom"; + +const storedApplicationPreviewStyleAtom = atomWithStorage( + APPLICATION_PREVIEW_STYLE_STORAGE_KEY, + APPLICATION_PREVIEW_STYLE.COMPACT +); + +/** + * Local-only visual preference shared by the setup preview and the global + * preferences panel. Unsupported persisted values recover to compact. + */ +export const applicationPreviewStyleAtom = atom( + (get): ApplicationPreviewStyle => + normalizeApplicationPreviewStyle(get(storedApplicationPreviewStyleAtom)), + (_get, set, value: unknown) => { + set( + storedApplicationPreviewStyleAtom, + normalizeApplicationPreviewStyle(value) + ); + } +); +applicationPreviewStyleAtom.debugLabel = "applicationPreviewStyleAtom"; diff --git a/src/store/ui/guideHighlightAtom.ts b/src/store/ui/guideHighlightAtom.ts index 123f5cff5..04b5242ca 100644 --- a/src/store/ui/guideHighlightAtom.ts +++ b/src/store/ui/guideHighlightAtom.ts @@ -25,3 +25,14 @@ export const clearGuideHighlightAtom = atom(null, (_get, set) => { set(guideHighlightAtom, null); }); clearGuideHighlightAtom.debugLabel = "clearGuideHighlightAtom"; + +/** Clear a guide only when the caller still owns the highlighted target. */ +export const clearGuideHighlightTargetAtom = atom( + null, + (get, set, targetId: string) => { + if (get(guideHighlightAtom)?.targetId === targetId) { + set(guideHighlightAtom, null); + } + } +); +clearGuideHighlightTargetAtom.debugLabel = "clearGuideHighlightTargetAtom"; diff --git a/src/store/ui/index.ts b/src/store/ui/index.ts index 0706ebaf7..7da3cd96e 100644 --- a/src/store/ui/index.ts +++ b/src/store/ui/index.ts @@ -56,6 +56,7 @@ export * from "./modelSelectorAtom"; export * from "./settingsToolbarAtom"; export * from "./globalTabsTypes"; export * from "./guideHighlightAtom"; +export * from "./globalPreferencesPanelAtom"; // WorkStation / Chat / Simulator / Workspace Folders (formerly workspaceAtom barrel) export * from "./simulatorAtom"; diff --git a/src/store/ui/runtimeNavigationAtom.ts b/src/store/ui/runtimeNavigationAtom.ts new file mode 100644 index 000000000..120e78f79 --- /dev/null +++ b/src/store/ui/runtimeNavigationAtom.ts @@ -0,0 +1,20 @@ +import { atom } from "jotai"; + +export type RuntimeOrganizationView = "today" | "members" | "sync"; + +/** + * One-shot navigation request for opening Runtime at a specific organization + * surface. The Runtime panel consumes and clears it after the requested cloud + * organization is available, so reopening Runtime later preserves the user's + * own selection instead of replaying an old guide action. + */ +export interface RuntimeNavigationIntent { + requestId: number; + orgId: string; + view: RuntimeOrganizationView; +} + +export const runtimeNavigationIntentAtom = atom( + null +); +runtimeNavigationIntentAtom.debugLabel = "runtimeNavigationIntentAtom"; diff --git a/src/store/ui/setupGuideDevScenarioAtom.test.ts b/src/store/ui/setupGuideDevScenarioAtom.test.ts new file mode 100644 index 000000000..8b1873570 --- /dev/null +++ b/src/store/ui/setupGuideDevScenarioAtom.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { + SETUP_GUIDE_DEV_SCENARIO, + resolveSetupGuideDevCloudOrg, + resolveSetupGuideDevRole, +} from "./setupGuideDevScenarioAtom"; + +describe("setup guide development scenarios", () => { + const realOrg = { orgId: "org-a", name: "ORG A", role: "member" }; + + it("preserves the authoritative object in live mode", () => { + expect( + resolveSetupGuideDevCloudOrg(realOrg, SETUP_GUIDE_DEV_SCENARIO.LIVE) + ).toBe(realOrg); + }); + + it("removes the organization only from the simulated journey", () => { + expect( + resolveSetupGuideDevCloudOrg( + realOrg, + SETUP_GUIDE_DEV_SCENARIO.NO_ORGANIZATION + ) + ).toBeNull(); + expect(realOrg.role).toBe("member"); + }); + + it.each([ + SETUP_GUIDE_DEV_SCENARIO.MEMBER, + SETUP_GUIDE_DEV_SCENARIO.ADMIN, + SETUP_GUIDE_DEV_SCENARIO.OWNER, + ])("overrides only the presentation role for %s", (scenario) => { + expect(resolveSetupGuideDevCloudOrg(realOrg, scenario)).toEqual({ + ...realOrg, + role: scenario, + }); + expect(resolveSetupGuideDevRole(realOrg.role, scenario)).toBe(scenario); + expect(realOrg.role).toBe("member"); + }); + + it("does not synthesize an organization for role scenarios", () => { + expect( + resolveSetupGuideDevCloudOrg(null, SETUP_GUIDE_DEV_SCENARIO.ADMIN) + ).toBeNull(); + }); +}); diff --git a/src/store/ui/setupGuideDevScenarioAtom.ts b/src/store/ui/setupGuideDevScenarioAtom.ts new file mode 100644 index 000000000..6f324f925 --- /dev/null +++ b/src/store/ui/setupGuideDevScenarioAtom.ts @@ -0,0 +1,53 @@ +import { atom } from "jotai"; + +export const SETUP_GUIDE_DEV_SCENARIO = { + LIVE: "live", + NO_ORGANIZATION: "no_organization", + MEMBER: "member", + ADMIN: "admin", + OWNER: "owner", +} as const; + +export type SetupGuideDevScenario = + (typeof SETUP_GUIDE_DEV_SCENARIO)[keyof typeof SETUP_GUIDE_DEV_SCENARIO]; +export type SetupGuideRoleScenario = + | typeof SETUP_GUIDE_DEV_SCENARIO.MEMBER + | typeof SETUP_GUIDE_DEV_SCENARIO.ADMIN + | typeof SETUP_GUIDE_DEV_SCENARIO.OWNER; + +/** + * Development-only presentation override for the onboarding invite journey. + * Runtime-only by design: it never changes the cloud roster or persists. + */ +export const setupGuideDevScenarioAtom = atom( + SETUP_GUIDE_DEV_SCENARIO.LIVE +); +setupGuideDevScenarioAtom.debugLabel = "setupGuideDevScenarioAtom"; + +export function isSetupGuideRoleScenario( + scenario: SetupGuideDevScenario +): scenario is SetupGuideRoleScenario { + return ( + scenario === SETUP_GUIDE_DEV_SCENARIO.MEMBER || + scenario === SETUP_GUIDE_DEV_SCENARIO.ADMIN || + scenario === SETUP_GUIDE_DEV_SCENARIO.OWNER + ); +} + +export function resolveSetupGuideDevCloudOrg( + realOrg: T | null, + scenario: SetupGuideDevScenario +): T | null { + if (scenario === SETUP_GUIDE_DEV_SCENARIO.NO_ORGANIZATION) return null; + if (scenario === SETUP_GUIDE_DEV_SCENARIO.LIVE || !realOrg) return realOrg; + return { ...realOrg, role: scenario }; +} + +export function resolveSetupGuideDevRole( + realRole: T | null | undefined, + scenario: SetupGuideDevScenario +): T | SetupGuideRoleScenario | null { + if (scenario === SETUP_GUIDE_DEV_SCENARIO.NO_ORGANIZATION) return null; + if (isSetupGuideRoleScenario(scenario)) return scenario; + return realRole ?? null; +} diff --git a/tests/e2e/specs/core/setup-walkthrough-shortcut-ui.spec.mjs b/tests/e2e/specs/core/setup-walkthrough-shortcut-ui.spec.mjs new file mode 100644 index 000000000..b7ddaf90f --- /dev/null +++ b/tests/e2e/specs/core/setup-walkthrough-shortcut-ui.spec.mjs @@ -0,0 +1,72 @@ +/* global describe, before, it, browser */ + +const WAIT_MS = 30_000; + +async function setupPreferencesVisible() { + return browser.executeScript( + ` + const element = document.querySelector('[data-testid="setup-preferences"]'); + if (!element) return false; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && + style.visibility !== "hidden" && style.display !== "none"; + `, + [] + ); +} + +describe("Setup walkthrough shortcut entry (rendered UI)", () => { + before(async () => { + await browser.setTimeout({ script: WAIT_MS }); + await browser.waitUntil( + async () => + browser.executeScript( + ` + try { + window.localStorage.setItem("orgii:auth_skipped", "1"); + return document.readyState !== "loading" && + document.querySelector("#root")?.childElementCount > 0; + } catch { + return false; + } + `, + [] + ), + { + timeout: WAIT_MS, + interval: 250, + timeoutMsg: "app root never mounted", + } + ); + }); + + it("handles the native-menu bridge, then survives reload", async () => { + await browser.executeScript( + ` + window.dispatchEvent(new CustomEvent("menu-reopen-setup")); + `, + [] + ); + + await browser.waitUntil(setupPreferencesVisible, { + timeout: WAIT_MS, + interval: 200, + timeoutMsg: "setup menu bridge did not open quick preferences", + }); + const firstPath = await browser.executeScript( + "return window.location.pathname;", + [] + ); + if (firstPath !== "/orgii/app/walkthrough") { + throw new Error(`shortcut opened unexpected route: ${firstPath}`); + } + + await browser.refresh(); + await browser.waitUntil(setupPreferencesVisible, { + timeout: WAIT_MS, + interval: 200, + timeoutMsg: "shortcut-reset onboarding state did not survive reload", + }); + }); +}); diff --git a/tests/e2e/specs/core/setup-walkthrough-ui.spec.mjs b/tests/e2e/specs/core/setup-walkthrough-ui.spec.mjs new file mode 100644 index 000000000..0e61f9266 --- /dev/null +++ b/tests/e2e/specs/core/setup-walkthrough-ui.spec.mjs @@ -0,0 +1,187 @@ +/* global describe, before, after, it, browser */ +/** + * Rendered proof for compact first-run preference setup. + * + * Fixture helpers only preserve/restore persisted settings and navigate. Every + * transition below uses the production buttons and the real settings writer; + * no debug helper marks a step complete or manufactures readiness. + */ +import { + invokeE2E, + unwrap, + waitForApp, +} from "../../support/core/agentOrgUiDriver.mjs"; + +const SETUP_ROUTE = "/orgii/app/walkthrough"; +const WAIT_MS = 30_000; +let originalSettings = null; + +async function visible(selector) { + await browser.waitUntil( + async () => + browser.executeScript( + ` + const element = document.querySelector(arguments[0]); + if (!element) return false; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && + style.visibility !== "hidden" && style.display !== "none"; + `, + [selector] + ), + { timeout: WAIT_MS, interval: 200, timeoutMsg: `${selector} not visible` } + ); +} + +async function click(selector) { + await visible(selector); + const element = await browser.$(selector); + await element.scrollIntoView({ block: "center", inline: "center" }); + await element.moveTo(); + await element.click(); +} + +describe("Quick setup preferences (rendered UI)", () => { + before(async function () { + await waitForApp(); + originalSettings = unwrap( + await invokeE2E("readSettings"), + "read setup settings" + ).settings; + unwrap( + await invokeE2E("navigateTo", SETUP_ROUTE), + "navigate to setup checklist" + ); + await visible('[data-testid="setup-preferences"]'); + if (process.env.E2E_SETUP_GOAL_SCREENSHOT) { + await browser.saveScreenshot(process.env.E2E_SETUP_GOAL_SCREENSHOT); + } + }); + + after(async function () { + if (!originalSettings) return; + unwrap( + await invokeE2E("writeSettingsPartial", { + "general.setupWalkthroughOutcome": + originalSettings["general.setupWalkthroughOutcome"], + "general.setupWalkthroughProgress": + originalSettings["general.setupWalkthroughProgress"], + "general.language": originalSettings["general.language"], + "general.theme": originalSettings["general.theme"], + "general.primaryColor": originalSettings["general.primaryColor"], + }), + "restore setup settings" + ); + }); + + it("persists a visible preference change and completes in one action", async () => { + const legacyStepCount = await browser.executeScript( + "return document.querySelectorAll('[data-testid^=setup-step-]').length;", + [] + ); + if (legacyStepCount !== 0) { + throw new Error(`quick setup still renders ${legacyStepCount} step rows`); + } + + await click('[data-testid="setup-primary-color"]'); + const violetOption = await browser.$( + '//*[normalize-space(text())="Violet"]' + ); + await violetOption.waitForDisplayed({ timeout: WAIT_MS }); + await violetOption.click(); + await browser.waitUntil( + async () => { + const settings = unwrap( + await invokeE2E("readSettings"), + "read changed quick-setup preference" + ).settings; + return settings["general.primaryColor"] === "violet"; + }, + { + timeout: WAIT_MS, + interval: 200, + timeoutMsg: "primary color selection was not persisted", + } + ); + + if (process.env.E2E_SETUP_SCREENSHOT) { + await browser.saveScreenshot(process.env.E2E_SETUP_SCREENSHOT); + } + await click('[data-testid="setup-finish"]'); + + await browser.waitUntil( + async () => + browser.executeScript( + "return window.location.pathname.startsWith('/orgii/workstation');", + [] + ), + { + timeout: WAIT_MS, + interval: 200, + timeoutMsg: "setup did not land in the Workstation", + } + ); + const completedSettings = unwrap( + await invokeE2E("readSettings"), + "read completed quick-setup settings" + ).settings; + const completedProgress = + completedSettings["general.setupWalkthroughProgress"]; + if ( + completedSettings["general.setupWalkthroughOutcome"] !== "completed" || + completedProgress?.currentStepId !== "preferences" || + !completedProgress?.completedStepIds?.includes("preferences") + ) { + throw new Error( + `quick setup did not commit completion atomically: ${JSON.stringify({ + outcome: completedSettings["general.setupWalkthroughOutcome"], + progress: completedProgress, + })}` + ); + } + }); + + it("reopens quick setup through the hidden release-build shortcut", async () => { + const isMac = await browser.executeScript( + "return navigator.platform.toUpperCase().includes('MAC');", + [] + ); + await browser.executeScript( + ` + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "o", + code: "KeyO", + metaKey: arguments[0], + ctrlKey: !arguments[0], + altKey: true, + shiftKey: false, + bubbles: true, + cancelable: true, + })); + `, + [isMac] + ); + + await visible('[data-testid="setup-preferences"]'); + const settings = unwrap( + await invokeE2E("readSettings"), + "read shortcut-reset setup settings" + ).settings; + const progress = settings["general.setupWalkthroughProgress"]; + + if ( + settings["general.setupWalkthroughOutcome"] !== "open" || + progress?.currentStepId !== "goal" || + progress?.goal !== null || + progress?.completedStepIds?.length !== 0 + ) { + throw new Error( + `hidden shortcut did not reset setup state: ${JSON.stringify({ + outcome: settings["general.setupWalkthroughOutcome"], + progress, + })}` + ); + } + }); +});