diff --git a/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx b/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx index cd96c768a7..6d183dffcb 100644 --- a/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx +++ b/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx @@ -2,6 +2,7 @@ import { Fragment } from "react" import { AvatarVariant, F0Avatar } from "@/components/avatars/F0Avatar" import { F0Button } from "@/components/F0Button" +import { Cross } from "@/icons/app" import { F0ButtonDropdown } from "@/components/F0ButtonDropdown" import { StatusVariant } from "@/components/tags/F0TagStatus" import { Description } from "@/experimental/Information/Headers/BaseHeader/Description" @@ -21,6 +22,7 @@ import { DropdownItem, MobileDropdown, } from "@/experimental/Navigation/Dropdown" +import { useI18n } from "@/lib/providers/i18n" import { cn } from "@/lib/utils" export type HeaderSecondaryButtonAction = SecondaryAction & { @@ -60,6 +62,8 @@ interface BaseHeaderProps { metadataRowGap?: MetadataProps["rowGap"] /** Renders a 1px bottom border at the very bottom of the header. */ showBottomBorder?: boolean + /** When set, renders a close button in the header actions that calls this on click. */ + onClose?: () => void } const isVisible = (action: { isVisible?: boolean }) => @@ -77,7 +81,9 @@ export function BaseHeader({ metadata = [], metadataRowGap = "none", showBottomBorder = false, + onClose, }: BaseHeaderProps) { + const i18n = useI18n() const allMetadata: BaseHeaderProps["metadata"] = [ status && { label: status.label, @@ -240,6 +246,17 @@ export function BaseHeader({ )} + {onClose && ( +
+ +
+ )}
@@ -308,6 +325,20 @@ export function BaseHeader({ />
)} + {onClose && ( + <> +
+
+ +
+ + )}
{allMetadata.length > 0 && ( diff --git a/packages/react/src/lib/storybook-utils/ai-mocks.ts b/packages/react/src/lib/storybook-utils/ai-mocks.ts index eb4e00318d..e87837c103 100644 --- a/packages/react/src/lib/storybook-utils/ai-mocks.ts +++ b/packages/react/src/lib/storybook-utils/ai-mocks.ts @@ -13,25 +13,32 @@ const MOCK_TRANSCRIPTS = [ ] as const /** - * Simulates a streaming STT endpoint: picks a random long transcript and emits - * it word by word so the surface fills live (Wispr Flow feel) without any - * backend. + * Builds a streaming STT mock from a pool of transcripts: picks a random one and + * emits it word by word so the surface fills live (Wispr Flow feel) without any + * backend. Use this to make voice dictation contextual to a given flow (e.g. + * survey-refinement phrasing in the co-creation story) instead of the generic + * pool below. */ -export const mockTranscribe: TranscribeFn = async ( - _audio, - { onPartial, signal } -) => { - const transcript = pickRandom(MOCK_TRANSCRIPTS) - const words = transcript.split(" ") - let acc = "" - for (const word of words) { - if (signal?.aborted) break - await new Promise((r) => setTimeout(r, 60 + Math.random() * 100)) - acc = acc ? `${acc} ${word}` : word - onPartial(acc) +export const makeMockTranscribe = + (transcripts: readonly string[]): TranscribeFn => + async (_audio, { onPartial, signal }) => { + const transcript = pickRandom(transcripts) + const words = transcript.split(" ") + let acc = "" + for (const word of words) { + if (signal?.aborted) break + await new Promise((r) => setTimeout(r, 60 + Math.random() * 100)) + acc = acc ? `${acc} ${word}` : word + onPartial(acc) + } + return transcript } - return transcript -} + +/** + * Default streaming STT mock over the generic transcript pool above. Backs the + * `WithVoiceDictation` stories that aren't tied to a specific flow. + */ +export const mockTranscribe: TranscribeFn = makeMockTranscribe(MOCK_TRANSCRIPTS) const MOCK_ENHANCED_TEXTS = [ `

Our quarterly review highlighted three priorities for the next cycle: improving the onboarding experience, reducing the time it takes to close payroll, and giving managers better visibility over team workloads.

Each priority now has a clear owner and a measurable target, so we can track progress week by week instead of waiting for the end of the quarter to find out where we stand.

`, diff --git a/packages/react/src/patterns/Cocreation/__stories__/accessibility.mdx b/packages/react/src/patterns/Cocreation/__stories__/accessibility.mdx new file mode 100644 index 0000000000..b846642855 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/accessibility.mdx @@ -0,0 +1,157 @@ +import { Meta, Unstyled } from "@storybook/addon-docs/blocks" +import { DoDonts } from "@/lib/storybook-utils/do-donts" + + + +# Accessibility + +Co-creation puts a conversation, a set of accept/reject decisions, and a live resource canvas +on screen at once, and changes them as the AI works. That makes a few things load-bearing: +the user must be able to **drive every decision from the keyboard**, **hear what the AI just +did** without watching the canvas, and **keep their place** when panels open, close, and +swap. The building blocks already implement most of this; this page states the contract the +pattern relies on so a new co-creation flow inherits it instead of re-deriving it. + +## Keyboard + +Everything the user can do with a pointer must be reachable from the keyboard, in a sensible +order, with a visible focus ring. + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SurfaceKeyboard behavior
+ Clarifying questions (F0ClarifyingPanel) + + Arrow keys move between options, Enter selects and + advances, and Esc cancels the whole flow. The panel + surfaces these hints inline ("navigate · Enter select · Esc cancel"). +
+ Proposal cards (accept / reject) + + The accept (✓) and reject (✗) actions are real buttons with text + labels, focusable in reading order. Resolving a proposal must not + strand focus (see below). +
+ Resource cards (Open / Close) + + Opening and closing the canvas is a focusable control on the card — + never a pointer-only target. Superseded cards drop their action and + leave the tab order. +
+ Header trigger (F0OneSwitch) + + The One switch is reachable in every phase, so the keyboard user can + always open, collapse, or return to the conversation. +
+
+ + + +## Focus + +The flow rearranges the screen as it advances — the chat animates in, the canvas opens, a +proposal resolves and its card changes. Move focus deliberately at each of these moments so a +keyboard or screen-reader user is never left on a control that just disappeared. + +- **Entering Chat** — move focus to the composer so the user can start typing immediately. +- **Opening the canvas (Split)** — move focus into the canvas (or its heading), not back to + the top of the page. +- **Resolving a proposal** — when an accepted change posts a new live card and supersedes the + old one, land focus on the new card (or the assistant's follow-up), not on the now-inert + superseded card. +- **Closing the canvas** — return focus to the card that opened it. + + + +## Announcements + +Much of what co-creation does is visual and asynchronous — the AI streams a reply, then an +overlay applies a change to the canvas. A screen-reader user needs those moments narrated. + +- **Applying a change** — `F0AiProcessingOverlay` renders with `role="status"` and + `aria-live="polite"`, so its "Applying changes" label is announced when the overlay appears + and the canvas is busy. +- **Streaming replies** — announce that the assistant is responding, and that it has + finished, via a polite live region. Announce the arrival of a proposal or a new resource + card so the decision isn't missed. + + + +## Motion + +The chat animating in and the processing overlay are both motion. Honor +`prefers-reduced-motion`: keep the state changes (so the user still knows what happened) but +drop or shorten the animation. `F0AiProcessingOverlay` already respects the reduced-motion +preference. + + + +For broader, component-level accessibility expectations, see the foundations docs; this page +covers only what is specific to co-creation. diff --git a/packages/react/src/patterns/Cocreation/__stories__/adoption.mdx b/packages/react/src/patterns/Cocreation/__stories__/adoption.mdx new file mode 100644 index 0000000000..82fda2e3e2 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/adoption.mdx @@ -0,0 +1,150 @@ +import { Meta, Unstyled } from "@storybook/addon-docs/blocks" +import { DoDonts } from "@/lib/storybook-utils/do-donts" + + + +# Adoption + +How to apply co-creation to a **new** resource. Co-creation is not a component you import — +it is an orchestration of existing AI blocks around a small state machine. Surveys is the +first instance; a job description, a policy draft, or a goal plan follows the same recipe. +This page is the recipe: the state to own, the blocks to wire, the contracts to honor, and +the few things that are actually specific to your resource. + +## 1. Own a single `phase` + +A co-creation flow runs on one chat-enabled +[`ApplicationFrame`](?path=/docs/patterns-applicationframe--documentation) and is driven by a +single `phase` value — `"collection" | "chat" | "split"`. Everything else (whether the chat +is open, and whether it shows full width or as a side panel) is **derived** from `phase`, +never set independently, so the header trigger and the in-flow buttons can't disagree. See +[Phases](?path=/docs/patterns-co-creation-phases--documentation) for what each phase is and +how the transitions fire. + + + +## 2. Wire the blocks (don't rebuild them) + +Reach for the existing AI blocks for every conversational part of the flow. You are +assembling, not authoring — when a block improves, your flow inherits it. Each is documented +on its own page; the catalog and responsibilities live in +[Building blocks](?path=/docs/patterns-co-creation-building-blocks--documentation). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ResponsibilityBlock
Open the AI from any phase + F0OneSwitch in the frame header +
The conversation surface, composer, and welcome screen + F0AiChat (plus its welcomeScreenCards /{" "} + welcomeScreenSuggestions) +
Narrow a vague intent before drafting + F0ClarifyingPanel +
The in-chat resource card and the accept/reject proposal card + F0CardHorizontal +
"Applying changes" over the canvas + F0AiProcessingOverlay +
+
+ +## 3. Honor the contracts + +These are the invariants that make every co-creation flow feel like the same pattern. They +are defined in full on the +[Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page: + +- **Canvas in chat** — the resource is built and edited in a canvas that opens inside the + chat, never on a separate page you route the user to. +- **Approve every change** — no AI proposal reaches the resource without an explicit accept; + surface each one as a confirm/reject card. +- **Leaving warns** — navigation that would abandon an in-progress creation confirms first; + merely collapsing the chat does not. +- **Card lifecycle** — keep only the latest card for a resource interactive (active / live), + and leave superseded cards in the chat as history. + +Co-creation is also held to the pattern's +[Accessibility](?path=/docs/patterns-co-creation-accessibility--documentation) contract — +keyboard, focus, announcements, and reduced motion. + +## 4. Bring only your resource surfaces + +The conversational machinery is shared; what's specific to your resource is small: + +- **The canvas surfaces** — a read-only **preview** and an editable **builder/editor** for + your resource (Surveys supplies `SurveyAnsweringForm` and `SurveyFormBuilder`). +- **Draft + apply logic** — how an intent becomes a first draft, and how an accepted proposal + is written into the resource. +- **Save model** — when the resource is persisted. A resource with a **Draft** state is created + on first creation (it exists in its domain from then on; later changes are saved explicitly); + a resource **without** a draft state creates nothing until the user explicitly saves. This + drives the "leaving warns" contract — a draft survives navigation, unsaved non-draft work is + lost. +- **Clarifying questions** — the handful that materially improve _your_ first draft (Surveys + asks type → audience → length). +- **Welcome cards** — the starting points worth offering on the welcome screen (blank, browse + templates, seed a known template). + + + +## Checklist + +When standing up a new co-creation flow: + +1. Mount it on one chat-enabled `ApplicationFrame` with `F0OneSwitch` in the header. +2. Model a single `phase`; derive the chat's open / visualization state from it. +3. Define the welcome cards and the clarifying questions for your resource. +4. Build the canvas preview and editor surfaces; open them **inside** the chat. +5. Post each AI change as a confirm/reject `F0CardHorizontal`; apply only on accept, behind + `F0AiProcessingOverlay`. +6. Manage the resource/proposal card lifecycle (active, live, superseded, resolved). +7. Warn before navigation that abandons an in-progress creation. +8. Verify the [Accessibility](?path=/docs/patterns-co-creation-accessibility--documentation) + contract — keyboard, focus, announcements, reduced motion. + +For the first end-to-end instance to read alongside this checklist, see +[Creation with AI](?path=/docs/patterns-co-creation-creation-with-ai--documentation). diff --git a/packages/react/src/patterns/Cocreation/__stories__/assets/chat-fullscreen.png b/packages/react/src/patterns/Cocreation/__stories__/assets/chat-fullscreen.png new file mode 100644 index 0000000000..c21160ccd5 Binary files /dev/null and b/packages/react/src/patterns/Cocreation/__stories__/assets/chat-fullscreen.png differ diff --git a/packages/react/src/patterns/Cocreation/__stories__/assets/split-empty.png b/packages/react/src/patterns/Cocreation/__stories__/assets/split-empty.png new file mode 100644 index 0000000000..87e65d465b Binary files /dev/null and b/packages/react/src/patterns/Cocreation/__stories__/assets/split-empty.png differ diff --git a/packages/react/src/patterns/Cocreation/__stories__/building-blocks.mdx b/packages/react/src/patterns/Cocreation/__stories__/building-blocks.mdx new file mode 100644 index 0000000000..8a064563fd --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/building-blocks.mdx @@ -0,0 +1,154 @@ +import { Meta, Unstyled } from "@storybook/addon-docs/blocks" + + + +# Building blocks + +Co-creation is not a single component — it is an **orchestration of existing SDS AI +components**. Each block owns one responsibility in the flow; the pattern wires them together +so every co-creation experience behaves consistently. Use this page to find the right block +and its dedicated documentation. + +## Catalog + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BlockRole in co-creationPhaseDocs
+ F0OneSwitch + + The One switch in the application header. Entry point that opens the + AI from any phase. + Collection → Chat + + AI / F0OneSwitch + +
+ F0AiChat + + The conversation surface and runtime. Hosts the messages, input, + welcome cards and suggestions, and in-chat cards. + Chat, Split + + AI / F0AiChat + +
+ F0ClarifyingPanel + + Structured clarifying questions that narrow the intent before drafting + (e.g. resource type, audience, length). + Chat + + AI / F0ClarifyingPanel + +
+ F0CardHorizontal + + The single card primitive used for both the in-chat resource card + (opening it moves the flow into Split and shows the resource in the + canvas) and the human-in-the-loop proposal card (a proposed change + surfaced as an explicit accept / reject before it is applied). Its + active, inactive, and confirm/reject variants drive the card + lifecycle. + Chat, Split + + Experimental / CardHorizontal + +
+ F0AiProcessingOverlay + + The "applying changes" overlay shown over the resource while a + proposed change is being written. + Split + + AI / F0AiProcessingOverlay + +
+
+ +## Behavior + +- The blocks are **independent and documented on their own pages** — co-creation does not + re-implement chat, clarifying questions, or confirmations. When a block changes, the + pattern inherits the change. +- The welcome screen has two independent, optional entry points, configured separately on + `F0AiChat`. They differ by **what a click does — a card fires a host action, a suggestion + sends a prompt**: + - **Welcome cards** (`welcomeScreenCards`) — a grid of cards **below** the composer + (full width only). Each card carries its own `onClick`, and the **host decides + the behavior** — open a canvas, seed a template, or send the card's optional `message`. + Capped at **four** (a 2×2 grid); extras are dropped. + ([With Welcome Cards](?path=/story/sds-ai-f0aichattextarea--with-welcome-cards)) + - **Welcome suggestions** (`welcomeScreenSuggestions`) — grouped outline buttons **above** the + composer, on both the side-panel and full-width welcome screens. Each group (icon + label) + opens a popover of starter prompts, and clicking one **sends its `prompt` straight to the + AI**. **No cap on the number of groups** yet; each group's popover shows up to five items. + ([With Welcome Suggestions](?path=/story/sds-ai-f0aichattextarea--with-welcome-suggestions)) + + Provide either, both, or neither — their counts are unrelated. + +- Human-in-the-loop confirmation — the `F0CardHorizontal` confirm/reject card — is the contract + that keeps the user in control: no AI-proposed change reaches the resource without an explicit + accept. +- The resource and proposal cards change state as the AI iterates — active, live, superseded, + pending, resolved. The + [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page documents + this card lifecycle in full. +- `F0AiProcessingOverlay` announces itself politely while it works — it renders with + `role="status"` and `aria-live="polite"`, so assistive tech hears "Applying changes" without + the user losing their place. See + [Accessibility](?path=/docs/patterns-co-creation-accessibility--documentation). diff --git a/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.mdx b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.mdx new file mode 100644 index 0000000000..37e4a22a17 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.mdx @@ -0,0 +1,174 @@ +import { Meta, Unstyled } from "@storybook/addon-docs/blocks" + +import chatFullscreen from "./assets/chat-fullscreen.png" +import splitEmpty from "./assets/split-empty.png" + + + +# Creation with AI + +**Creation with AI** is the first concrete instance of the +[Co-creation](?path=/docs/patterns-co-creation--documentation) pattern — the abstract flow +grounded in one real resource. The resource here is a survey, but the structure is the +pattern's, not the survey's: the user starts on a collection (Collection), describes what they +want in a chat (Chat), then refines the resulting draft on a canvas while the conversation +continues (Split). The walkthrough below maps each phase to what the survey example shows; the +mock itself runs full screen, so open it in its own canvas: + + + + Open the interactive flow ↗ + + + +## Flow + +### Collection + +The flow starts on the collection the user is already working in — a Surveys data collection in +this example. The user opens the AI from the header One switch (side panel) or the **Create** +primary button (full width). + +### Chat + + +
+ The full-width chat welcome screen, with an action-driven header, a composer, and four starting-point cards below it. +
+ The chat opens full width, with an action-driven header. The user types + what they want (or dictates it with the mic), or picks a starting-point + card. +
+
+
+ +The chat opens with welcome cards offering a few starting points. The cards are +data-driven (`welcomeScreenCards`) and rendered below the composer on the full-width welcome +screen; they're optional, and you can show up to four. Which starting points to offer is up to +each flow — the survey example fills all four: + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Starting pointWhat it does
+ Empty survey + + Creates a blank survey and walks the user through it with clarifying + questions. +
+ Templates + + Opens the templates canvas to browse and preview pre-made surveys. +
+ Employee NPS + + Seeds a ready-made eNPS survey; the eNPS question is locked so scores + stay comparable, the rest is editable. +
+ Employee Engagement + Seeds a ready-made employee-engagement survey to edit.
+
+ +Clarifying questions narrow the intent before anything is drafted — here, the survey's type, +audience, and length. The draft is then surfaced as an in-chat resource card; opening it moves +the flow into Split. + +### Split + + +
+ The split view: a survey builder fills the center while the chat docks on the right, showing the resource card and a clarifying question. +
+ After choosing Empty survey: the resource canvas fills the center, the + chat docks on the right with the resource card and clarifying questions. +
+
+
+ +The resource canvas fills the center while the chat docks on the right. Every co-created +resource needs a way to read the draft and a way to edit it; here the survey canvas offers those +as two modes: + + + + + + + + + + + + + + + + + + + +
ModeWhat the user sees
+ Preview + + The survey rendered as it will be answered (the answering form), for + reviewing a template or draft. +
+ Edit + + The survey builder, with a Questions tab for the survey content and a + Settings tab for metadata (title, description, participants, schedule, + visibility, editors). +
+
+ +The canvas header carries the resource actions — here **Save** and a primary **Publish** +— so the user can keep the draft or ship it without leaving the flow. + +As the conversation continues, the AI proposes changes (for example, adding an open-ended +comment question). Each proposal appears as a human-in-the-loop confirmation card; the user +accepts or rejects it, and an overlay covers the canvas while an accepted change is applied. + +## Composed building blocks + +This flow assembles the blocks documented in +[Building blocks](?path=/docs/patterns-co-creation-building-blocks--documentation): +`F0OneSwitch`, `F0AiChat` (including its data-driven welcome cards and voice dictation), +`F0ClarifyingPanel`, `F0CardHorizontal` (the in-chat resource and proposal cards), and +`F0AiProcessingOverlay`. Only the surfaces specific to this example — the survey's answering +form and builder — come from the +[Surveys](?path=/docs/sds-surveys-surveyformbuilder--documentation) SDS. diff --git a/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.stories.tsx b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.stories.tsx new file mode 100644 index 0000000000..6ab8580622 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.stories.tsx @@ -0,0 +1,2100 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { z } from "zod" + +import { + ComponentProps, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react" +import { StandardLayout } from "@/layouts/StandardLayout" +import { PageHeader } from "@/experimental/Navigation/Header/PageHeader" +import { F0CardHorizontal } from "@/experimental/F0CardHorizontal" +import { + Add, + ArrowLeft, + Check, + Cross, + Delete, + ExternalLink, + File, + Files, + LayersFront, + Marketplace, + Pencil, + Settings, + SolidPlay, +} from "@/icons/app" +import { F0Alert } from "@/components/F0Alert" +import { ButtonInternal } from "@/components/F0Button/internal" +import { F0Heading } from "@/components/F0Heading" +import { dialogs } from "@/lib/providers/dialogs-alike" +import { ButtonGroup } from "@/ui/ButtonGroup" +import { ApplicationFrame } from "@/patterns/ApplicationFrame" +import { Page as NavigationPage } from "@/patterns/Navigation/Page" +import { Tabs } from "@/patterns/Navigation/Tabs" +import { Sidebar } from "@/patterns/Navigation/Sidebar/Sidebar" +import * as SidebarStories from "@/patterns/Navigation/Sidebar/index.stories" +import { OneDataCollection } from "@/patterns/OneDataCollection" +import { useDataCollectionSource } from "@/patterns/OneDataCollection/hooks/useDataCollectionSource" +import { ResourceHeader } from "@/patterns/ResourceHeader" +import { useAiChat } from "@/sds/ai/F0AiChat" +import type { ClarifyingOption } from "@/sds/ai/F0ClarifyingPanel" +import { + type CanvasContent, + type CanvasContentBase, + type CanvasEntityDefinition, +} from "@/sds/ai/canvas" +import { F0AiProcessingOverlay } from "@/sds/ai/F0AiProcessingOverlay" +import { + type ClarifyingStep, + MockAiChatRuntimeProvider, + MockConnectedChatHeader, + MockConnectedChatInput, + MockConnectedMessagesContainer, + useMockAiChatRuntime, +} from "@/sds/ai/F0AiChat/__stories__/_mock" + +import { f0FormField, F0Form } from "@/patterns/F0Form" +import type { F0SectionConfig } from "@/patterns/F0Form" +import { useF0FormDefinition } from "@/patterns/F0WizardForm" +import { SurveyAnsweringForm } from "@/sds/surveys/SurveyAnsweringForm" +import { SurveyFormBuilder } from "@/sds/surveys/SurveyFormBuilder/Form" +import type { SurveyFormBuilderElement } from "@/sds/surveys/SurveyFormBuilder/types" +import { mockDatasets } from "@/sds/surveys/__stories__/mocks" + +import { + cardVisualization, + filledDataAdapter, + resourceFilters, + resourceSortings, + tableVisualization, + templateFilters, + templatesDataAdapter, + templateSortings, +} from "./mockData" +import type { Template } from "./mockData" +// WIP: temporary toast mock — replace with "@/hooks/toast" once +// https://github.com/factorialco/f0/pull/3493 merges, then remove this import. +import { toasts } from "@/hooks/toast" +import { useI18n } from "@/lib/providers/i18n" +import { + makeInitialSurveyElements, + mockSurveyTranscribe, + NPS_SURVEY_ELEMENTS, + SURVEY_DEFAULT_VALUES, + SURVEY_ELEMENTS, +} from "./survey-mocks" +import { TAB_CONFIGS } from "./tab-configs" +import type { TabConfig } from "./tab-configs" +import type { F0AiChatWelcomeCard } from "@/sds/ai/F0AiChat" + +/** + * Co-creation patterns — "Creation with AI". + * + * Interactive mockup of the AI-creation flow, built on a single chat-enabled + * ApplicationFrame so the "One" switch (the F0AiChat trigger) is always in the + * header. There are three phases: + * 1. Collection — data collection. Open the chat via the header "One" switch + * (side panel) OR the "Create" primary button (full width). + * 2. Chat — F0AiChat animates in so the user can describe what they + * want (full width via the "Create" button, side panel via the One switch). + * 3. Split — the chat docks as the right side panel and the resource + * (a document/preview canvas) fills the center. + * + * `phase` is the single source of truth; the chat's open/visualization state is + * derived from it (and kept in sync when the user toggles the One switch). + * Self-contained: this file owns its own mock world. + * + * The scripted conversation choreography — canned replies, the clarifying-question + * flow, the chat → split transition, and the appended resource/proposal cards — is + * authored below, one block per entry point (Empty survey, typed "Create", and the + * template cards). + */ +const meta = { + title: "Co-creation/Creation with AI", + // Manual MDX docs live in creation-with-ai.mdx; opt out of the globally + // enabled autodocs so the section shows a single Documentation page. + tags: ["!autodocs"], + parameters: { + layout: "fullscreen", + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +type Phase = "collection" | "chat" | "split" + +const COCREATION_MODULE = { + id: "ats" as const, + name: "Co-creation", + href: "/cocreation", +} + +// `AiChatStateProvider` persists the chat's open/visualization-mode state to +// localStorage. We reset those keys once on mount so the chat always starts +// CLOSED in the collection view, regardless of a previous session. +const AI_CHAT_STORAGE_KEYS = [ + "ONE-ai-chat-open", + "ONE-ai-chat-visualization-mode", +] +const resetAiChatPersistence = () => { + try { + AI_CHAT_STORAGE_KEYS.forEach((key) => window.localStorage.removeItem(key)) + } catch { + // localStorage may be unavailable (SSR / privacy mode) — ignore. + } +} + +// --------------------------------------------------------------------------- +// Tab-config context — exposes the live active tab (and its config) to +// `FlowContent`, which reads it to drive the collection tab strip, the chat's +// opening intent message, and the resource canvas shown in the split view. +// --------------------------------------------------------------------------- + +type TabConfigContextValue = { + activeTabId: string + setActiveTabId: (id: string) => void + tabConfig: TabConfig +} + +const TabConfigContext = createContext(null) + +function useTabConfig(): TabConfigContextValue { + const ctx = useContext(TabConfigContext) + if (!ctx) { + throw new Error("useTabConfig must be used inside ") + } + return ctx +} + +function TabConfigProvider({ + initialTabId = "surveys", + children, +}: { + initialTabId?: string + children: ReactNode +}) { + const [activeTabId, setActiveTabId] = useState(initialTabId) + const tabConfig = TAB_CONFIGS[activeTabId] ?? TAB_CONFIGS.surveys + + return ( + + {children} + + ) +} + +function SurveySettingsForm() { + const formSchema = z.object({ + title: f0FormField.text({ + label: "Title", + section: "basic", + placeholder: "Enter survey title", + }), + description: f0FormField.textarea({ + label: "Description", + section: "basic", + optional: true, + rows: 3, + }), + participants: f0FormField.select({ + label: "Select participants", + section: "participants", + options: [ + { value: "all", label: "All employees" }, + { value: "department", label: "By department" }, + { value: "custom", label: "Custom selection" }, + ], + placeholder: "Select participants", + }), + publishOn: f0FormField.date({ + label: "Publish on", + section: "schedule", + row: "schedule-dates", + optional: true, + }), + endsAt: f0FormField.date({ + label: "Ends at", + section: "schedule", + row: "schedule-dates", + optional: true, + }), + recurrence: f0FormField.select({ + label: "Recurrence", + section: "schedule", + options: [ + { value: "none", label: "Does not repeat" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, + { value: "quarterly", label: "Quarterly" }, + ], + }), + managerVisibility: f0FormField.boolean({ + label: "Add visibility permissions to managers and team leads", + helpText: + "Grant access to managers and team leads so they can view results for their own teams once responses are available.", + section: "visibility", + optional: true, + }), + anonymousAnswers: f0FormField.boolean({ + label: "Anonymous answers", + section: "visibility", + optional: true, + }), + editors: f0FormField.select({ + label: "Select editors", + section: "editors", + options: [ + { value: "none", label: "None" }, + { value: "admins", label: "Administrators only" }, + { value: "custom", label: "Custom selection" }, + ], + placeholder: "Select editors", + }), + }) + + const sections: Record = { + basic: { title: "Basic Information" }, + participants: { + title: "Participants", + description: "Choose who will receive this survey", + action: { label: "Manage groups", icon: ExternalLink, href: "#groups" }, + }, + schedule: { title: "Schedule" }, + visibility: { + title: "Visibility & Privacy", + description: + "Configure the visibility and privacy settings for this survey", + action: { + label: "Privacy settings", + icon: Settings, + onClick: () => {}, + }, + }, + editors: { title: "Editors" }, + } + + const formDefinition = useF0FormDefinition({ + name: "survey-settings", + schema: formSchema, + sections, + defaultValues: { + title: "Employee engagement survey", + description: + "A 10-question pulse check covering motivation, clarity, and team dynamics.", + participants: undefined, + publishOn: undefined, + endsAt: undefined, + recurrence: "none", + managerVisibility: false, + anonymousAnswers: true, + editors: "admins", + }, + onSubmit: async () => ({ success: true }), + // The header Save persists the whole resource (questions + settings), so the + // settings form carries no save affordance of its own. + submitConfig: { hideActionBar: true, hideSubmitButton: true }, + }) + + return ( + + ) +} + +/** + * The survey-templates browse view as a card-view data collection. Shared by + * BOTH the collection "Templates" tab and the AI Canvas (see + * `templatesCanvasEntity`) so the cards + their metadata stay identical in both + * places as the data collection / card config evolves. + */ +function TemplatesCollection({ + onSelect, +}: { + onSelect?: (item: Template) => void +} = {}) { + const source = useDataCollectionSource({ + dataAdapter: templatesDataAdapter, + filters: templateFilters, + sortings: templateSortings, + search: { enabled: true, sync: true }, + // No card CTAs — actions live on the preview surface. The card-body click + // is omitted entirely when no handler is supplied so the browse-tab cards + // stay inert (no pointer, no action); in the AI Canvas it opens the preview. + itemOnClick: onSelect ? (item) => () => onSelect(item) : undefined, + }) + return ( + + ) +} + +// Canvas content + entity for the survey templates. `CanvasContent` is a closed +// SDS union (dashboard | form | dataDownload); "templates" is story-specific, so +// we keep a local content type and cast at the `openCanvas` call site — the +// canvas registry/panel match on the `type` string at runtime. +type TemplatesCanvasContent = CanvasContentBase & { type: "templates" } +const TEMPLATES_CANVAS_CONTENT: TemplatesCanvasContent = { + type: "templates", + title: "Templates", +} + +const templatesCanvasEntity: CanvasEntityDefinition = { + type: "templates", + // Standard canvas header: title (from content) on the left, close on the + // right — mirrors the design system's panel-header structure (no + // ResourceHeader chrome). Rendered as a component so closing can return to the + // first step of cocreation rather than just dismissing the canvas (see + // `TemplatesCanvasHeader`); the framework `onClose` is intentionally dropped. + renderHeader: ({ content }) => , + renderContent: () => ( +
+ +
+ ), + overflowHidden: true, +} + +// Story-local survey canvas. Like `templatesCanvasEntity`, the "survey" type is +// story-specific (not part of the closed SDS `CanvasContent` union), so we keep +// a local content type and cast at the `openCanvas` call site. `mode` selects +// the surface: "preview" is the read-only `SurveyAnsweringForm` (template +// browsing), while "edit" is the editable resource view — a `ResourceHeader` + +// Questions/Settings tabs over the interactive `SurveyFormBuilder` (see +// `SurveyEditorCanvasHeader` / `SurveyEditorCanvasBody`). +type SurveyCanvasContent = CanvasContentBase & { + type: "survey" + mode: "edit" | "preview" + templateName: string + /** + * Identifies which survey in the multi-survey store this canvas renders. The + * editable "edit" mode always carries one (minted by `createSurvey`); the + * read-only template "preview" omits it (it renders static sample questions). + */ + surveyId?: string + /** Carried from the template card into the edit view's resource header. */ + description?: string + /** Blank "start from scratch" survey — the editable builder, seeded empty. */ + empty?: boolean +} + +// Bridge the story-local canvas types to the SDS API. `CanvasContent` / +// `CanvasEntityDefinition` are a *closed* SDS union that doesn't include the +// custom "survey"/"templates" entities this story demos, so we widen at the +// provider boundary (the registry/panel match on the `type` string at runtime). +// Centralized here so the unavoidable cast — and the reason for it — live in one +// place instead of being repeated at every call site. +type StoryCanvasContent = SurveyCanvasContent | TemplatesCanvasContent + +const toCanvasContent = (content: StoryCanvasContent): CanvasContent => + content as unknown as CanvasContent + +const toCanvasEntity = ( + entity: + | CanvasEntityDefinition + | CanvasEntityDefinition +): CanvasEntityDefinition => entity as unknown as CanvasEntityDefinition + +// Inverse of `toCanvasContent`: read the story content back out of the SDS union. +const asSurveyCanvasContent = ( + content: CanvasContent | null +): SurveyCanvasContent | null => + content as unknown as SurveyCanvasContent | null + +// Default name for a blank "start from scratch" survey, shown until the guided +// flow resolves a real name. +const UNTITLED_SURVEY_NAME = "Untitled survey" + +// Name of the survey seeded by the "Employee NPS" welcome card. +const EMPLOYEE_NPS_SURVEY_NAME = "Employee NPS" + +// Build the canvas content for a blank survey opened by the "Empty survey" +// welcome card (or the typed "Create" flow). Reuses the survey canvas entity +// (mode "edit") but with no sample questions: it mirrors a real Survey resource +// view — a `ResourceHeader` + Questions/Settings tab strip — with the +// interactive `SurveyFormBuilder` (seeded empty) living under the "Questions" +// tab (see `SurveyEditorCanvasHeader` / `SurveyEditorCanvasBody`). The +// `surveyId` ties it to its entry in the multi-survey store. +const makeEmptySurveyContent = ( + surveyId: string, + name: string = UNTITLED_SURVEY_NAME +): SurveyCanvasContent => ({ + type: "survey", + title: name, + mode: "edit", + templateName: name, + empty: true, + surveyId, +}) + +// In-chat survey card, built on `F0CardHorizontal`. Its Open/Close button drives +// the single shared canvas. The card derives `isActive` by matching the open +// `canvasContent` to its own `surveyId`, so many survey cards coexist in one chat +// and only one survey shows at a time. A survey can post a series of cards as it +// evolves (created → updated) — only the latest (`live`) card stays interactive; +// superseded cards render faded + inert (no Open/Close), keeping the chat history +// visible. Posted via the mock runtime's `appendCard` (generativeUI slot), which +// re-invokes the render each pass so `isActive` / `live` stay reactive. +function SurveyCard({ + surveyId, + cardId, + title, + description, +}: { + surveyId: string + cardId: string + title: string + description: string +}) { + const { canvasContent, openCanvas, setVisualizationMode } = useAiChat() + const { isLiveCard } = useSurveyStore() + const live = isLiveCard(surveyId, cardId) + // `canvasContent` is the closed SDS union (dashboard | form | dataDownload); + // the story-local "survey" type is matched at runtime, so widen to read it. + const surveyContent = asSurveyCanvasContent(canvasContent) + const isActive = + surveyContent?.type === "survey" && surveyContent.surveyId === surveyId + + const handleOpen = () => + openCanvas( + toCanvasContent({ + type: "survey", + title, + mode: "edit", + templateName: title, + surveyId, + description, + }) + ) + + // Closing from the card collapses the canvas but keeps the chat docked as a + // side panel (split). The mode before the canvas opened is always either + // "sidepanel" or "fullscreen"; we preserve the side-panel case and, when it was + // fullscreen, deliberately fall to side panel rather than back to fullscreen — + // both resolve to "sidepanel". Switching modes here also clears the canvas + // content (the provider drops it on any canvas → non-canvas transition), so we + // don't need the generic `closeCanvas` (which would restore fullscreen). + const handleClose = () => setVisualizationMode("sidepanel") + + // Superseded cards (no longer live) are inert: faded, non-interactive, no + // Open/Close button. The live card shows the toggle — "Close" while its survey + // is open in the canvas, "Open" otherwise. + return ( +
+ +
+ ) +} + +// Subtitle shown on a survey's "created" card (blank or from a template). +const SURVEY_CREATED_DESCRIPTION = "Created in Engagement / Surveys" +// Subtitle shown on the "updated" card posted once the AI drafts the questions. +const SURVEY_UPDATED_DESCRIPTION = "Survey updated with your choices." + +// Toast titles reused across the survey resource-header actions and autosave. +const SURVEY_SAVED_TOAST = "Survey saved in Engagement / Surveys" +const SURVEY_PUBLISHED_TOAST = "Survey published" +const SURVEY_DUPLICATED_TOAST = "Survey duplicated" +const SURVEY_DELETED_TOAST = "Survey deleted" + +// The single open-ended question the AI "proposes" adding once a template has +// been applied and the user describes a change. Appended to the survey (kept +// alongside the template's questions) when the user accepts the proposal. +const PROPOSED_QUESTION_ELEMENT = { + type: "question", + question: { + id: "q-proposed-comment", + title: "What would most improve your experience?", + type: "longText" as const, + }, +} satisfies SurveyFormBuilderElement + +// Label + subtitle shared by the proposal card across its pending/resolved +// states, so the resolved card reads as the same proposal, now decided. +const PROPOSED_CHANGE_TEXT = "Add an open-ended comment question" +const PROPOSED_CHANGE_DESCRIPTION = + "A new question at the end: “What would most improve your experience?”" + +/** + * Human-in-the-loop proposal card posted whenever the user describes a change + * to an existing survey. Built on `F0CardHorizontal`'s confirm/reject variant: + * it owns its own pending → resolved state. Accepting + * runs the "applying changes" overlay, appends the proposed question, and posts + * a fresh live `SurveyCard` — which supersedes the prior card for the survey + * (its Open/Close button drops and it fades to opacity-50). Either way the card + * itself flips to its resolved `status` (the ✓/✗ buttons give way to an outcome + * icon) and the flow is re-armed, so a further typed message proposes again. + * Rendered via `appendCard` (generativeUI slot) inside the provider tree, so it + * can read the chat runtime + survey store via hooks. + */ +function ProposalConfirmationCard({ + surveyId, + surveyTitle, +}: { + surveyId: string + surveyTitle: string +}) { + const [resolution, setResolution] = useState< + "pending" | "accepted" | "rejected" + >("pending") + const { appendMessages, appendCard } = useMockAiChatRuntime() + const { applyChange, getSurvey, nextCardId, registerLiveCard } = + useSurveyStore() + // Re-arm after the user decides, so the NEXT typed message proposes again — + // the propose → confirm → update loop repeats for as long as they keep typing. + const { armProposal } = useProposalFlow() + + // No avatar on the update card — the proposed change isn't attributed to the + // Engagement module (unlike the resource cards), so the row leads with its text. + if (resolution === "accepted") { + return ( + + ) + } + if (resolution === "rejected") { + return ( + + ) + } + + return ( + { + setResolution("accepted") + // Mint a unique question id so repeated accepts never collide. + const proposed: SurveyFormBuilderElement = { + type: "question", + question: { + ...PROPOSED_QUESTION_ELEMENT.question, + id: `q-proposed-${nextCardId()}`, + }, + } + applyChange( + surveyId, + (els) => [...els, proposed], + () => { + // A fresh live card for the SAME survey: the prior card linking to + // it drops its Open/Close button and fades to opacity-50. + const cardId = nextCardId() + registerLiveCard(surveyId, cardId) + appendCard(() => ( + + )) + appendMessages([ + { + role: "assistant", + content: "Done — I've added the question to your survey.", + }, + ]) + armProposal(surveyId, surveyTitle) + } + ) + }, + }} + rejectAction={{ + label: "Discard", + onClick: () => { + setResolution("rejected") + appendMessages([ + { + role: "assistant", + content: "No problem — I've left the survey unchanged.", + }, + ]) + armProposal(surveyId, surveyTitle) + }, + }} + /> + ) +} + +/** + * Arms the next typed message to propose a change to an existing survey: the + * assistant posts a proposal line and a human-in-the-loop + * `ProposalConfirmationCard`. Shared by every entry point (Empty survey, typed + * "Create", and Templates) once the survey has been created, and re-armed by the + * card itself after each decision — so further typing keeps re-triggering the + * propose → confirm → update loop, each accepted change superseding the previous + * card pointing to the survey. + */ +function useProposalFlow() { + const { appendMessages, appendCard, setUserMessageInterceptor } = + useMockAiChatRuntime() + const armProposal = useCallback( + (surveyId: string, surveyTitle: string) => { + setUserMessageInterceptor(() => { + appendMessages([ + { + role: "assistant", + content: + "Good idea. Here's a change I'd suggest — review it below.", + }, + ]) + appendCard(() => ( + + )) + }) + }, + [appendMessages, appendCard, setUserMessageInterceptor] + ) + return { armProposal } +} + +// Survey-type options offered as a clarifying question right after a blank +// survey is created (mirrors the examples the assistant used to list inline). +const SURVEY_TYPE_OPTIONS: ClarifyingOption[] = [ + { id: "engagement", label: "Employee engagement" }, + { id: "onboarding", label: "Onboarding feedback" }, + { id: "pulse", label: "Customer pulse check" }, + { id: "enps", label: "eNPS" }, +] + +// Follow-up clarifying questions walked after the survey type (audience, then +// length) before the AI "drafts" the questions onto the canvas. +const SURVEY_AUDIENCE_OPTIONS: ClarifyingOption[] = [ + { id: "all", label: "All employees" }, + { id: "department", label: "A specific department" }, + { id: "custom", label: "A custom group" }, +] + +const SURVEY_LENGTH_OPTIONS: ClarifyingOption[] = [ + { id: "short", label: "Short (3–5 questions)" }, + { id: "standard", label: "Standard (6–10 questions)" }, + { id: "deep", label: "In-depth (10+ questions)" }, +] + +// The three clarifying questions — type → audience → length — walked as a +// single consecutive flow inside one panel: picking an answer advances to the +// next question (the header shows a "X of Y" counter and a back arrow) and the +// final question submits. Shared by both the "Empty survey" card and the typed +// "Create" flow, which differ only in when the canvas opens. +const SURVEY_CLARIFYING_STEPS: ClarifyingStep[] = [ + { + question: "What kind of survey are you working on?", + options: SURVEY_TYPE_OPTIONS, + selectionMode: "single", + }, + { + question: "Who should receive this survey?", + options: SURVEY_AUDIENCE_OPTIONS, + selectionMode: "single", + }, + { + question: "How long should the survey be?", + options: SURVEY_LENGTH_OPTIONS, + selectionMode: "single", + }, +] + +// Once the consecutive flow resolves, echo the user's picks back into the +// transcript as a SINGLE user message — each question in bold with the chosen +// answer on the line beneath it (mirrors the real product, which consolidates +// the clarifying answers into one reply instead of one bubble per step). +const surveyAnswerMessages = ( + answersByStep: string[][] +): { role: "user"; content: string }[] => { + // answersByStep is index-aligned with SURVEY_CLARIFYING_STEPS (buildAnswers in + // MockAiChatRuntime maps over every step, in order). + const blocks = SURVEY_CLARIFYING_STEPS.map((step, i) => { + const answer = (answersByStep[i] ?? []).join(", ").trim() + if (!answer) return null + // Trailing backslash = CommonMark hard line break, so the answer renders + // directly UNDER the bold question (a plain "\n" is only a soft break/space). + return `**${step.question}**\\\n${answer}` + }).filter((block): block is string => block !== null) + + if (blocks.length === 0) return [] + // Blank line between pairs → separate

blocks, spaced by the bubble's gap. + return [{ role: "user" as const, content: blocks.join("\n\n") }] +} + +// Survey name derived from the chosen type once the guided flow completes — the +// canvas header switches from "Untitled survey" to this. +const surveyNameForType = (typeLabel: string): string => `${typeLabel} survey` + +// Confirmation dialogs use the imperative Alert/Confirm API from Library/Dialogs +// (`dialogs.confirmation`, rendered by the `DialogsAlikeLayoutProvider` that `F0Provider` +// mounts). No open-state context needed — any trigger just calls these and awaits +// the result (the picked action's value: `true` for confirm, `false` for cancel). + +// Shared "leave creation" confirmation — opened by the preview header's "Edit +// Template" action and the in-canvas alert's "Templates" link. +const confirmLeaveCreation = () => + dialogs.confirmation({ + title: "Leave creation?", + msg: "Editing this template opens it in Surveys / Templates. Are you sure you want to leave the current creation flow?", + confirm: { label: "Go to Edit Template" }, + cancel: { label: "Cancel" }, + }) + +// "Unsaved changes" confirmation shown when "Publish" is pressed — offers to save +// before publishing. Resolves `true` when the user confirms. +const confirmPublish = () => + dialogs.confirmation({ + title: "Unsaved Changes", + msg: "You have unsaved changes, would you like to Save the changes before publishing?", + confirm: { label: "Save Changes and Publish" }, + cancel: { label: "Cancel" }, + }) + +// "Unsaved changes" confirmation shown when the canvas Close (X) is pressed. +// Closing leaves the in-progress draft, so the user gets the three ways out: +// save the changes then close, close without saving, or cancel and stay. +// Resolves the picked action's value ("save" | "discard" | "cancel"); dismissing +// the dialog (backdrop / Esc) resolves `undefined`, also treated as "stay". +const confirmCloseUnsaved = () => + dialogs.notification({ + type: "warning", + title: "Unsaved changes", + msg: "You have unsaved changes. Save them to Surveys before closing the canvas?", + actions: { + primary: { label: "Save and close", value: "save" }, + secondary: [ + { label: "Close without saving", value: "discard" }, + { label: "Cancel", value: "cancel" }, + ], + }, + }) + +/** + * The non-dismissable info banner shown atop a template preview. The "Templates" + * link doesn't navigate (this is a mock); it opens the same leave-creation + * confirmation as the header's "Edit Template" action. F0Alert's link is + * href-only, so the click is intercepted on capture. + */ +function TemplatePreviewAlert() { + return ( +

{ + if ((e.target as HTMLElement).closest("a")) { + e.preventDefault() + e.stopPropagation() + void confirmLeaveCreation() + } + }} + > + +
+ ) +} + +/** + * Header for the template-PREVIEW survey canvas (mode "preview"). Two exits: + * "Back to templates" (left) re-opens the templates list, and "Close" (right) + * returns to the starting point of the flow — the fullscreen welcome screen — + * the same behaviour as the templates-list canvas' close. The framework + * `onClose` is intentionally ignored in favour of these. "Use this template" + * swaps in the editable resource view (mode "edit"), carrying the template's + * name and description so its `ResourceHeader` is populated. Defined as a + * component so it can read `openCanvas` / `setVisualizationMode` from context. + */ +function SurveyCanvasHeader({ content }: { content: SurveyCanvasContent }) { + const { openCanvas, setVisualizationMode } = useAiChat() + const { appendCard, appendMessages } = useMockAiChatRuntime() + const { createSurvey, nextCardId, registerLiveCard } = useSurveyStore() + const { armProposal } = useProposalFlow() + + const useThisTemplate = () => { + // A template copy is created already populated, so seed the sample questions + // (empty: false). No AI drafting follows, so this single card stays live. + const surveyId = createSurvey(content.templateName, { empty: false }) + openCanvas( + toCanvasContent({ + type: "survey", + title: content.templateName, + mode: "edit", + templateName: content.templateName, + surveyId, + description: content.description, + }) + ) + // Acknowledge the survey created from this template — an openable canvas card + // (template name + subtitle) followed by a guided follow-up question. + const cardId = nextCardId() + registerLiveCard(surveyId, cardId) + appendCard(() => ( + + )) + appendMessages([ + { + role: "assistant", + content: + "I have created the survey using the template. What would you like to customize or change?", + }, + ]) + // Arm the next typed message: the user describes a change, the assistant + // proposes one, and a human-in-the-loop confirmation card lets them accept + // (apply + supersede the card) or reject it — then re-arms for the next. + armProposal(surveyId, content.templateName) + } + + return ( +
+ openCanvas(toCanvasContent(TEMPLATES_CANVAS_CONTENT))} + /> +
+ +
+ {/* ButtonGroup owns the responsive collapse: when the row is wide both + actions show; when the title grows or the canvas narrows, the + secondary "Edit Template" sheds into the "⋯" menu while the primary + "Use this template" stays pinned. */} + void confirmLeaveCreation(), + }, + ]} + /> + {/* Close returns to the starting point of the flow (fullscreen welcome), + mirroring the templates-list canvas' close. */} + setVisualizationMode("fullscreen")} + /> +
+ ) +} + +// How long the canvas stays blurred under the "applying changes" overlay before +// the drafted questions land. +const SURVEY_DRAFTING_MS = 2200 + +/** + * One survey's draft state held in the multi-survey store: the form-builder + * `elements`, the survey `name`, and a `processing` flag for the AI "applying + * changes" overlay. + */ +type SurveyEntry = { + elements: SurveyFormBuilderElement[] + name: string + processing: boolean +} + +/** + * The multi-survey store, shared across the whole flow. Keyed by `surveyId` so + * MANY distinct surveys can coexist in one chat (each in-chat `SurveyCard` opens + * its own survey into the single shared canvas). Lives ABOVE the chat + canvas: + * the clarifying chain in the chat footer drafts into a survey, the canvas + * (rendered separately in the AI panel) shows it, and the chat cards read it — + * so no single one of them can own it alone. Also tracks which card is the + * `live` (interactive) one per survey, so a superseded card can render disabled. + */ +type SurveyStoreValue = { + /** + * Register + seed a new survey, returning its stable id. Pass `elements` to + * seed an explicit question set (e.g. the "Employee NPS" welcome card). Else + * `empty` starts blank (the AI drafts questions in later); otherwise it seeds + * the sample questions (a "Use this template" copy). + */ + createSurvey: ( + name: string, + opts?: { empty?: boolean; elements?: SurveyFormBuilderElement[] } + ) => string + /** Read a survey entry (undefined before creation, e.g. a template preview). */ + getSurvey: (surveyId?: string) => SurveyEntry | undefined + /** Replace a survey's questions (builder edits), scoped to one survey. */ + setElements: (surveyId: string, elements: SurveyFormBuilderElement[]) => void + /** + * Rename the survey, blur its canvas for a beat, then drop in a full set of + * mock questions — the AI "drafting" the survey at the end of the flow. + * `onComplete` fires once the questions land (the form is updated). + */ + draftQuestions: ( + surveyId: string, + name: string, + onComplete?: () => void + ) => void + /** + * Apply an AI-proposed edit to a survey: blur its canvas for a beat (the + * "applying changes" overlay), then transform its questions with `mutate` + * (e.g. appending a new question). Unlike `draftQuestions` it keeps the + * existing questions, so the change reads as a diff. `onComplete` fires once + * the new questions land. + */ + applyChange: ( + surveyId: string, + mutate: (els: SurveyFormBuilderElement[]) => SurveyFormBuilderElement[], + onComplete?: () => void + ) => void + /** Mint a unique id for a freshly-posted chat card. */ + nextCardId: () => string + /** Mark `cardId` as the live (interactive) card for `surveyId`. */ + registerLiveCard: (surveyId: string, cardId: string) => void + /** Whether `cardId` is the current live card for `surveyId`. */ + isLiveCard: (surveyId: string, cardId: string) => boolean + /** True once any survey has had its questions drafted (elements.length > 0). */ + hasDraftedSurvey: boolean +} + +const SurveyStoreContext = createContext(null) + +function useSurveyStore(): SurveyStoreValue { + const ctx = useContext(SurveyStoreContext) + if (!ctx) { + throw new Error("useSurveyStore must be used inside ") + } + return ctx +} + +function SurveyStoreProvider({ children }: { children: ReactNode }) { + const [surveys, setSurveys] = useState>({}) + const [liveCardBySurvey, setLiveCardBySurvey] = useState< + Record + >({}) + // Monotonic counter minting both survey and card ids — unique within a session + // (avoids Date.now/random; deterministic for the mock). + const idRef = useRef(0) + const timersRef = useRef>>({}) + + useEffect( + () => () => { + Object.values(timersRef.current).forEach(clearTimeout) + }, + [] + ) + + const createSurvey = useCallback( + (name, opts) => { + const surveyId = `survey-${(idRef.current += 1)}` + setSurveys((prev) => ({ + ...prev, + [surveyId]: { + // A blank "empty" survey is seeded with its first section/question + // (the same starting point the form builder would auto-add) so that + // state is durable in the store instead of appearing only as a + // render-time side effect. + elements: + opts?.elements ?? + (opts?.empty ? makeInitialSurveyElements() : SURVEY_ELEMENTS), + name, + processing: false, + }, + })) + return surveyId + }, + [] + ) + + const getSurvey = useCallback( + (surveyId) => (surveyId ? surveys[surveyId] : undefined), + [surveys] + ) + + const setElements = useCallback( + (surveyId, elements) => { + setSurveys((prev) => + prev[surveyId] + ? { ...prev, [surveyId]: { ...prev[surveyId], elements } } + : prev + ) + }, + [] + ) + + const draftQuestions = useCallback( + (surveyId, name, onComplete) => { + setSurveys((prev) => + prev[surveyId] + ? { + ...prev, + [surveyId]: { ...prev[surveyId], name, processing: true }, + } + : prev + ) + timersRef.current[surveyId] = setTimeout(() => { + setSurveys((prev) => + prev[surveyId] + ? { + ...prev, + [surveyId]: { + ...prev[surveyId], + elements: SURVEY_ELEMENTS, + processing: false, + }, + } + : prev + ) + onComplete?.() + }, SURVEY_DRAFTING_MS) + }, + [] + ) + + const applyChange = useCallback( + (surveyId, mutate, onComplete) => { + setSurveys((prev) => + prev[surveyId] + ? { ...prev, [surveyId]: { ...prev[surveyId], processing: true } } + : prev + ) + timersRef.current[surveyId] = setTimeout(() => { + setSurveys((prev) => + prev[surveyId] + ? { + ...prev, + [surveyId]: { + ...prev[surveyId], + elements: mutate(prev[surveyId].elements), + processing: false, + }, + } + : prev + ) + onComplete?.() + }, SURVEY_DRAFTING_MS) + }, + [] + ) + + const nextCardId = useCallback( + () => `card-${(idRef.current += 1)}`, + [] + ) + + const registerLiveCard = useCallback( + (surveyId, cardId) => { + setLiveCardBySurvey((prev) => ({ ...prev, [surveyId]: cardId })) + }, + [] + ) + + const isLiveCard = useCallback( + (surveyId, cardId) => liveCardBySurvey[surveyId] === cardId, + [liveCardBySurvey] + ) + + const hasDraftedSurvey = Object.values(surveys).some( + (s) => s.elements.length > 0 + ) + + return ( + + {children} + + ) +} + +/** + * Shared state for the editable survey canvas (mode "edit" — both the blank + * "Empty survey" and a "Use this template" copy). The canvas header and body + * are emitted by separate render functions (`renderHeader` / `renderContent`), + * so the active tab (Questions / Settings) can't live in either alone — it's + * held here and provided via the entity `wrapper`, which brackets both. The + * `elements` (and the `processing` overlay flag) come from the multi-survey + * store (keyed by `surveyId`) so the chat's clarifying chain can draft into them. + */ +type SurveyEditorCanvasState = { + tabId: string + setTabId: (id: string) => void + elements: SurveyFormBuilderElement[] + setElements: (elements: SurveyFormBuilderElement[]) => void + name: string + processing: boolean + /** Id of the survey this canvas edits — keys the one-shot first-creation save. */ + surveyId: string | undefined +} + +// Survey ids whose first-creation save toast has already fired. Module-scoped so +// it survives the canvas body unmounting on close and remounting on reopen, which +// would otherwise reset a per-mount ref and re-fire the toast every time. +const createdSurveyIds = new Set() + +const SurveyEditorCanvasContext = createContext( + null +) + +function useSurveyEditorCanvas(): SurveyEditorCanvasState { + const ctx = useContext(SurveyEditorCanvasContext) + if (!ctx) { + throw new Error( + "useSurveyEditorCanvas must be used inside the survey canvas wrapper" + ) + } + return ctx +} + +function SurveyCanvasStateProvider({ + content, + children, +}: { + content: SurveyCanvasContent + children: ReactNode +}) { + // Default to the "editor" (Questions) tab — the survey-creation surface is + // the point of the edit flow. + const [tabId, setTabId] = useState("editor") + // Read this survey's draft from the store by id. The survey is seeded once at + // creation time (`createSurvey`), so reopening its canvas never resets it — + // each survey keeps its own questions as the user switches between cards. + const store = useSurveyStore() + const survey = store.getSurvey(content.surveyId) + const elements = survey?.elements ?? [] + const name = survey?.name ?? content.templateName + const processing = survey?.processing ?? false + const surveyId = content.surveyId + const setElements = useCallback( + (next: SurveyFormBuilderElement[]) => { + if (surveyId) store.setElements(surveyId, next) + }, + [store, surveyId] + ) + return ( + + {children} + + ) +} + +/** Total questions across the survey, counting those nested in sections. */ +function countQuestions(elements: SurveyFormBuilderElement[]): number { + return elements.reduce( + (n, el) => + el.type === "section" ? n + (el.section.questions?.length ?? 0) : n + 1, + 0 + ) +} + +/** + * Header for the editable survey canvas. Mirrors a real Survey resource view: a + * `ResourceHeader` (title, optional description, Draft status, Publish action, + * "⋯" menu, metadata) stacked over the Questions / Settings tab strip — the + * same chrome the split phase renders for a created survey. The "Questions" + * metadata reflects the live question count from the shared canvas state. + */ +function SurveyEditorCanvasHeader({ + content, + onClose, +}: { + content: SurveyCanvasContent + onClose: () => void +}) { + const { tabId, setTabId, elements, name } = useSurveyEditorCanvas() + const i18n = useI18n() + return ( + <> + + void confirmPublish().then((ok) => { + if (ok) + toasts.open({ + title: SURVEY_PUBLISHED_TOAST, + variant: "success", + }) + }), + }} + onClose={() => + void confirmCloseUnsaved().then((action) => { + // Cancel or dismissed → stay on the canvas. + if (action === "cancel" || action === undefined) return + if (action === "save") + toasts.open({ + title: SURVEY_SAVED_TOAST, + variant: "success", + }) + onClose() + }) + } + secondaryActions={[ + { + label: i18n.actions.save, + onClick: () => + toasts.open({ + title: SURVEY_SAVED_TOAST, + variant: "success", + }), + }, + ]} + otherActions={[ + { + label: "Duplicate", + icon: LayersFront, + onClick: () => toasts.open({ title: SURVEY_DUPLICATED_TOAST }), + }, + { type: "separator" }, + { + label: "Delete", + icon: Delete, + critical: true, + onClick: () => + toasts.open({ title: SURVEY_DELETED_TOAST, variant: "error" }), + }, + ]} + metadata={[ + { + label: "Recurrence", + value: { type: "text", content: "Does not repeat" }, + }, + { + label: "Finishes on", + value: { type: "text", content: "Never ends" }, + }, + { + label: "Questions", + value: { type: "text", content: String(countQuestions(elements)) }, + }, + ]} + /> + + + ) +} + +/** + * Body for the editable survey canvas. The survey-creation surface — the + * interactive `SurveyFormBuilder` (seeded blank for the empty survey, or with + * the template's questions for a "Use this template" copy) — lives under the + * "Questions" tab; "Settings" reuses the resource view's `SurveySettingsForm`. + */ +function SurveyEditorCanvasBody() { + const { tabId, elements, setElements, processing, surveyId } = + useSurveyEditorCanvas() + // This flow's resource carries a Draft status, so creating it persists a draft + // to its domain the moment its canvas first opens — the resource's first + // creation (not autosave), fired once per survey across every creation path + // (empty survey, "Use this template" copy, predefined template). Keyed in a + // module-scoped set so closing and reopening the canvas doesn't re-fire it. + // From here saves are explicit (the header's "Save"); resources + // without a draft state create nothing until the user explicitly saves. + useEffect(() => { + if (!surveyId || createdSurveyIds.has(surveyId)) return + const t = setTimeout(() => { + createdSurveyIds.add(surveyId) + toasts.open({ + title: SURVEY_SAVED_TOAST, + variant: "success", + }) + }, 600) + return () => clearTimeout(t) + }, [surveyId]) + return ( + // While the AI is "drafting" questions, blur + lock the builder behind the + // "applying changes" overlay. + +
+ {tabId === "settings" ? ( + + ) : ( + + )} +
+
+ ) +} + +const surveyCanvasEntity: CanvasEntityDefinition = { + type: "survey", + // Shared tab/elements state for the editable (mode "edit") canvas, bracketing + // both the header (tab strip) and the body (tab content). Harmless for the + // read-only "preview" mode, which doesn't read it. + wrapper: ({ content, children }) => ( + + {children} + + ), + // "preview" is the read-only template browse view (its own thin header + + // answering form); "edit" (incl. the empty survey) is the editable resource + // view — ResourceHeader + Questions/Settings tabs over the form builder. + renderHeader: ({ content, onClose }) => + content.mode === "preview" ? ( + + ) : ( + + ), + renderContent: ({ content }) => + content.mode === "preview" ? ( +
+ + +
+ ) : ( + + ), +} + +/** + * Templates browse view as rendered inside the AI Canvas. Unlike the collection + * "Templates" tab (inert), the card click here is wired: it opens the template + * in preview framing (replacing the template list in-place via `openCanvas`). + * The actual "Use this template" / "Edit Template" actions live on the preview + * header, not the card. + */ +function TemplatesCanvasBody() { + const { openCanvas } = useAiChat() + const openPreview = (item: Template) => + openCanvas( + toCanvasContent({ + type: "survey", + title: item.name, + mode: "preview", + templateName: item.name, + description: item.description, + }) + ) + return +} + +/** + * Header for the templates browse canvas. Defined as a component (like + * `SurveyCanvasHeader`) so it can read context and run a custom close instead of + * the framework `onClose`: closing the templates list returns to the FIRST step + * of cocreation — the fullscreen welcome screen (suggestions + welcome cards). + * + * Switching to "fullscreen" both closes the canvas (the provider drops canvas + * content on any canvas → non-canvas transition) and reopens the chat full + * width. We force fullscreen rather than letting `closeCanvas` restore the + * pre-canvas mode, since templates may be opened from a side-panel welcome + * screen and the first step is always the fullscreen chat. + */ +function TemplatesCanvasHeader({ + content, +}: { + content: TemplatesCanvasContent +}) { + const { setVisualizationMode } = useAiChat() + + const handleClose = () => { + setVisualizationMode("fullscreen") + } + + return ( +
+ + +
+ ) +} + +/** + * Registers the survey entry-point cards shown below the composer on the + * fullscreen welcome screen, via the chat's data-driven `welcomeScreenCards` + * prop. Each card opens the AI Canvas (docks beside the chat): "Empty survey" + * opens a blank survey + kicks off a scripted guided conversation; "Templates" + * opens the templates card collection. Renders nothing — it only feeds card + * data into the provider so `F0AiChatTextArea` owns the layout. + */ +function SurveyWelcomeCardsRegistrar() { + const { appendCard, appendMessages, startClarifying } = useMockAiChatRuntime() + const { openCanvas, setWelcomeScreenCards, setPlaceholders } = useAiChat() + const { + createSurvey, + draftQuestions, + nextCardId, + registerLiveCard, + hasDraftedSurvey, + } = useSurveyStore() + const { armProposal } = useProposalFlow() + + // Switch the input placeholder based on whether a survey draft exists. + useEffect(() => { + setPlaceholders( + hasDraftedSurvey + ? ["Improve the Survey by..."] + : ["Describe the type of survey you want to create"] + ) + }, [hasDraftedSurvey, setPlaceholders]) + + // The blank-survey conversation walks three clarifying questions — type → + // audience → length — as a single consecutive panel, then "drafts" the + // questions onto the canvas: echo the picks back into the transcript, post a + // drafting line, and (after a brief processing beat) fill the form with mock + // questions plus an "updated" card that supersedes the "created" one. + const askSurveyDetails = (surveyId: string) => + startClarifying({ + steps: SURVEY_CLARIFYING_STEPS, + onConfirm: (answersByStep) => { + appendMessages(surveyAnswerMessages(answersByStep)) + appendMessages([ + { + role: "assistant", + content: + "Great — I'll draft a first set of questions on the canvas for you to review.", + }, + ]) + const surveyName = surveyNameForType( + answersByStep[0]?.[0] ?? "Untitled" + ) + // Once the form is drafted (questions land), post a new openable card + // and mark it live — which disables Open/Close on the "created" card — + // then arm the proposal flow so further typing keeps refining the + // survey (each accepted change supersedes the prior card in turn). + draftQuestions(surveyId, surveyName, () => { + const cardId = nextCardId() + registerLiveCard(surveyId, cardId) + appendCard(() => ( + + )) + armProposal(surveyId, surveyName) + }) + }, + }) + + const cards: F0AiChatWelcomeCard[] = [ + { + id: "empty-survey", + icon: File, + title: "Empty survey", + description: "Start from scratch", + onClick: () => handleCardSelectRef.current("empty-survey"), + }, + { + id: "templates", + icon: Marketplace, + title: "Templates", + description: "Browse pre-made surveys", + onClick: () => handleCardSelectRef.current("templates"), + }, + { + id: "employee-nps", + icon: Files, + title: "Employee NPS", + description: "Template", + onClick: () => handleCardSelectRef.current("employee-nps"), + }, + // Visual-only template card — its `onClick` hits no branch below, so + // clicking is inert (mock placeholder, no behavior wired yet). + { + id: "employee-engagement", + icon: Files, + title: "Employee Engagement", + description: "Template", + onClick: () => handleCardSelectRef.current("employee-engagement"), + }, + ] + // Welcome-card behavior, keyed by card `id`. Each card's `onClick` (above) + // routes here through `handleCardSelectRef`; each branch opens the AI Canvas + // and seeds the matching guided flow. + const handleCardSelect = (id: string) => { + switch (id) { + case "empty-survey": { + // Blank-survey flow: create + seed the survey, open its canvas, then + // post the guided sequence — an intro line, the "created" canvas card + // (live/openable), and the first clarifying question (the chain walks + // the rest). + const surveyId = createSurvey(UNTITLED_SURVEY_NAME, { empty: true }) + openCanvas(toCanvasContent(makeEmptySurveyContent(surveyId))) + appendMessages([ + { role: "assistant", content: "Let's start with a blank survey." }, + ]) + const cardId = nextCardId() + registerLiveCard(surveyId, cardId) + appendCard(() => ( + + )) + askSurveyDetails(surveyId) + break + } + case "templates": { + // Browse-only: open the templates list in the canvas WITHOUT posting a + // chat message, so the chat stays on the welcome screen. Closing the + // list without choosing a template (see `TemplatesCanvasHeader`) then + // returns to the fullscreen welcome screen with the welcome cards intact. + openCanvas(toCanvasContent(TEMPLATES_CANVAS_CONTENT)) + break + } + case "employee-nps": { + // Predefined-template flow: open a ready-made NPS survey on the canvas + // (mirrors "Use this template" — seeded with questions, no AI + // drafting). Its first question is a blocked eNPS question (locked + + // in-card warning); the rest stay editable. + const surveyId = createSurvey(EMPLOYEE_NPS_SURVEY_NAME, { + elements: NPS_SURVEY_ELEMENTS, + }) + openCanvas( + toCanvasContent({ + type: "survey", + title: EMPLOYEE_NPS_SURVEY_NAME, + mode: "edit", + templateName: EMPLOYEE_NPS_SURVEY_NAME, + surveyId, + description: SURVEY_CREATED_DESCRIPTION, + }) + ) + const cardId = nextCardId() + registerLiveCard(surveyId, cardId) + appendCard(() => ( + + )) + appendMessages([ + { + role: "assistant", + content: + "I've set up an Employee NPS survey. The eNPS question at the top is fixed so your scores stay comparable — the rest is yours to edit. What would you like to change?", + }, + ]) + armProposal(surveyId, EMPLOYEE_NPS_SURVEY_NAME) + break + } + // "employee-engagement" is a visual-only placeholder — no behavior yet. + } + } + + // Each card's `onClick` closes over runtime hooks via `handleCardSelectRef`, + // so the registered cards always call the latest handler. Keep the cards in a + // ref too, so a single mount effect registers them without re-running + // (`setWelcomeScreenCards` is stable). Clear them on unmount. + const cardsRef = useRef(cards) + cardsRef.current = cards + const handleCardSelectRef = useRef(handleCardSelect) + handleCardSelectRef.current = handleCardSelect + useEffect(() => { + setWelcomeScreenCards(cardsRef.current) + return () => { + setWelcomeScreenCards([]) + } + }, [setWelcomeScreenCards]) + + return null +} + +/** + * `Tabs` driven by `id` (controlled) render each tab as a `` rather than + * an ``, so the browser shows no pointer cursor — in product the tabs + * carry real hrefs and behave as links. This thin wrapper restores the pointer + * cursor on the story's controlled tab strips. `display: contents` keeps the + * wrapper layout-transparent. + */ +function ClickableTabs(props: ComponentProps) { + return ( +
+ +
+ ) +} + +/** + * The page content rendered inside the shared chat-enabled ApplicationFrame. + * Derives the chat's open/visualization state from `phase` and keeps `phase` in + * sync when the user toggles the header One switch. + */ +function FlowContent({ + phase, + setPhase, +}: { + phase: Phase + setPhase: (phase: Phase) => void +}) { + const { activeTabId, setActiveTabId, tabConfig } = useTabConfig() + const i18n = useI18n() + // Primary (module-level) navigation. In production this would be a single + // "Survey" item; "Tasks" is a second item added purely so the nav renders as + // a real tab strip (a single-tab `Tabs` collapses to a plain heading). + const [topNavId, setTopNavId] = useState("survey") + // The Surveys resource view has its own tab strip (Editor / Settings); the + // survey questions show under "Editor", which is the default focused tab in + // the co-creation flow. + const [surveyTabId, setSurveyTabId] = useState("editor") + const { open, setOpen, visualizationMode, setVisualizationMode, openCanvas } = + useAiChat() + const { + inProgress, + appendMessages, + appendCard, + startClarifying, + setUserMessageInterceptor, + } = useMockAiChatRuntime() + const { createSurvey, draftQuestions, nextCardId, registerLiveCard } = + useSurveyStore() + const { armProposal } = useProposalFlow() + + // Typed "Create" flow: the same three clarifying questions as the Empty survey + // card, walked as a single consecutive panel — but the canvas stays closed + // until the end, opening with the drafted survey once the final question is + // answered. (The Empty survey card opens the canvas up front instead.) + const runTypedClarifyingChain = () => { + // Create the blank survey up front — before the clarifying flow — seeded + // with its first section/question. The canvas stays closed until the final + // answer; the survey is only named, shown, and drafted then. + const surveyId = createSurvey(UNTITLED_SURVEY_NAME, { empty: true }) + startClarifying({ + steps: SURVEY_CLARIFYING_STEPS, + onConfirm: (answersByStep) => { + appendMessages(surveyAnswerMessages(answersByStep)) + const name = surveyNameForType(answersByStep[0]?.[0] ?? "Untitled") + openCanvas(toCanvasContent(makeEmptySurveyContent(surveyId, name))) + appendMessages([ + { + role: "assistant", + content: + "Great — I'll draft a first set of questions on the canvas for you to review.", + }, + ]) + // Unlike the Empty-survey flow, the clarifying questions are answered + // BEFORE the canvas opens here, so a "created" → "updated" pair would + // land back-to-back with nothing between them. Post a single live card + // instead; drafting fills the canvas behind it without superseding. + const cardId = nextCardId() + registerLiveCard(surveyId, cardId) + appendCard(() => ( + + )) + // Once drafting lands, arm the proposal flow so a further typed message + // proposes an update — which, on accept, posts an "updated" card that + // supersedes this initial "created" one. + draftQuestions(surveyId, name, () => armProposal(surveyId, name)) + }, + }) + } + + const sharedSourceOptions = { + filters: resourceFilters, + sortings: resourceSortings, + search: { + enabled: true, + sync: true, + }, + primaryActions: () => [ + { + // The single primary "Create" button launches the chat FULL WIDTH + // (fullscreen). It arms the chat so the user's first typed message kicks + // off the guided clarifying flow; the canvas opens with the drafted + // survey at the end. + label: "Create", + icon: Add, + onClick: () => { + setVisualizationMode("fullscreen") + setPhase("chat") + setUserMessageInterceptor(() => { + appendMessages([ + { + role: "assistant", + content: + "Sure — let's set up your survey. A few quick questions first.", + }, + ]) + runTypedClarifyingChain() + }) + }, + }, + ], + } + + const sourceTable = useDataCollectionSource({ + dataAdapter: filledDataAdapter, + ...sharedSourceOptions, + }) + + // phase → chat open state. Opening from "collection" flips `open` false→true + // while the chat is closed (so `shouldPlayEntranceAnimation` is true), which + // plays the expand-in animation. The mode is NOT forced for "chat": the + // header One switch opens a side panel (the chat's default), while the + // "Create" button sets fullscreen before entering this phase. + useEffect(() => { + if (phase === "collection") { + setOpen(false) + } else if (phase === "split") { + setVisualizationMode("sidepanel") + setOpen(true) + } else { + setOpen(true) + } + }, [phase, setOpen, setVisualizationMode]) + + // Keep `phase` in sync when the user toggles the chat via the header One + // switch (or the chat's own close button). Guarded on an actual `open` + // transition so it never fights the phase→open effect above. + const prevOpenRef = useRef(open) + useEffect(() => { + const wasOpen = prevOpenRef.current + prevOpenRef.current = open + if (wasOpen === open) return + if (open && phase === "collection") { + setPhase("chat") + } else if (!open && phase !== "collection") { + // The user closed the chat mid-creation — tear the flow down directly. + setPhase("collection") + } + }, [open, phase, setPhase]) + + return ( + + + {phase === "split" ? ( + // The Surveys canvas mirrors a real Survey resource view: a + // page-level ResourceHeader (the single header — the inline survey + // form's own header is suppressed) plus an Editor/Settings tab + // strip. + <> + + void confirmPublish().then((ok) => { + if (ok) + toasts.open({ + title: SURVEY_PUBLISHED_TOAST, + variant: "success", + }) + }), + }} + onClose={() => setPhase("chat")} + secondaryActions={[ + { + label: i18n.actions.save, + onClick: () => + toasts.open({ + title: SURVEY_SAVED_TOAST, + variant: "success", + }), + }, + ]} + otherActions={[ + { + label: "Duplicate", + icon: LayersFront, + onClick: () => + toasts.open({ title: SURVEY_DUPLICATED_TOAST }), + }, + { type: "separator" }, + { + label: "Delete", + icon: Delete, + critical: true, + onClick: () => + toasts.open({ + title: SURVEY_DELETED_TOAST, + variant: "error", + }), + }, + ]} + metadata={[ + { label: "Owner", value: { type: "text", content: "You" } }, + { + label: "Recurrence", + value: { type: "text", content: "Does not repeat" }, + }, + { + label: "Finishes on", + value: { type: "text", content: "—" }, + }, + { + label: "Questions", + value: { type: "text", content: "10" }, + }, + ]} + /> + + + ) : ( + visualizationMode !== "fullscreen" && ( + <> + + {topNavId === "survey" && ( + + )} + + ) + )} + + } + > + + {phase === "split" ? ( + // The left panel hosts the resource being co-created. While the AI is + // thinking/updating the form we blur + lock it (with the "Applying + // changes" pill) so the user can't edit content that's about to change. + + {surveyTabId === "editor" ? ( + + ) : surveyTabId === "settings" ? ( + + ) : ( + <> + )} + + ) : topNavId === "tasks" ? ( + // "Tasks" is a placeholder second item for the primary nav — no real + // content, just enough to show the navigation switching. +
+ No tasks yet. +
+ ) : activeTabId === "templates" ? ( + + ) : ( + + )} +
+
+ ) +} + +function CreationWithAIFlow({ initialTabId }: { initialTabId?: string }) { + // Reset persisted chat state once, before the provider reads it, so the chat + // starts closed in the collection view. + const didResetRef = useRef(false) + if (!didResetRef.current) { + resetAiChatPersistence() + didResetRef.current = true + } + + const [phase, setPhase] = useState("collection") + + const ai: ComponentProps["ai"] = { + enabled: true, + // Surface the "New conversation ▾" selector + history dialog (reuses the + // shared F0AiChatHistory pattern via MockConnectedChatHeader). + historyEnabled: true, + chatHeader: , + chatMessages: , + chatInput: , + // Voice dictation: a mic button in the composer streams a spoken-style + // survey-refinement request (follow-up questions + triggers) into the + // textarea for the user to review and send — see `mockSurveyTranscribe`. + onTranscribe: mockSurveyTranscribe, + // Single phrase → the colorful heading types in once and stays (a + // multi-element array would loop: type → hold → erase → next). + initialMessage: ["What kind of survey do you want to create?"], + // Prompt actions rendered as outline buttons at the top of the text area + // on the welcome screen. Each group opens a popover of starter prompts. + welcomeScreenSuggestions: [ + { + icon: Pencil, + label: "Create a survey for...", + items: [ + { + title: "Employee satisfaction survey", + prompt: + "Create an employee satisfaction survey covering workload, management, and work-life balance.", + }, + { + title: "Onboarding feedback survey", + prompt: + "Draft a survey to collect feedback from new hires about their first 90 days.", + }, + { + title: "Remote work pulse check", + prompt: + "Build a short pulse survey about how the team is experiencing remote work.", + }, + ], + }, + ], + // The "Templates" welcome card opens this entity in the AI Canvas; picking + // a template swaps in the "survey" entity in the same canvas panel. + // Story-local content types ("templates"/"survey") aren't part of the + // closed SDS `CanvasContent` union, so the entity definitions are widened to + // the registry's base type here. + canvasEntities: { + templates: toCanvasEntity(templatesCanvasEntity), + survey: toCanvasEntity(surveyCanvasEntity), + }, + resizable: true, + // Start closed in sidepanel mode so the chat plays its entrance animation + // when opened from the collection view. + defaultVisualizationMode: "sidepanel", + } + + return ( + + + + } + > + {/* Feeds the survey welcome cards into the chat via + `welcomeScreenCards`; renders nothing itself. */} + + + + + + + ) +} + +export const Surveys: Story = { + render: () => , +} diff --git a/packages/react/src/patterns/Cocreation/__stories__/guidelines.mdx b/packages/react/src/patterns/Cocreation/__stories__/guidelines.mdx new file mode 100644 index 0000000000..f1a2b555b5 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/guidelines.mdx @@ -0,0 +1,218 @@ +import { Meta, Unstyled } from "@storybook/addon-docs/blocks" +import { DoDonts } from "@/lib/storybook-utils/do-donts" + + + +# Guidelines + +When to reach for co-creation, when to use something else, and the principles that keep the +human in control of what the AI produces. + +## When to use + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SituationUse co-creation when
Creating a structured resource from a vague intent + The user can describe the goal in words but a blank form would be slow + or intimidating (a survey, a job description, a policy draft). +
The first draft benefits from a conversation + Clarifying questions meaningfully improve the output before generation + (audience, tone, length, scope). +
The resource is refined iteratively + The user wants to keep adjusting the draft while staying in dialogue, + rather than committing once and editing later. +
Context from the originating surface matters + Creation should happen over the collection or page the user came from, + not in a separate tool. +
+
+ +## When not to use + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SituationUse instead
Open-ended questions with no resource as output + + F0AiChat + {" "} + on its own +
A short, well-defined record with a few known fields + A{" "} + + CRUD dialog + {" "} + from Data collection +
A long, deterministic flow with required steps in a fixed order + A{" "} + + wizard form + +
Applying a change the user should not be able to undo casually + A{" "} + + confirmation dialog + {" "} + with an explicit destructive action +
+
+ +{/* TODO(creator): confirm the exact anti-patterns and where co-creation appears / what triggers it in Factorial. */} + +## Principles + + + + + + + + + + + + + + + + + +The [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page defines +these rules in full, including the resource-card lifecycle. diff --git a/packages/react/src/patterns/Cocreation/__stories__/index.mdx b/packages/react/src/patterns/Cocreation/__stories__/index.mdx new file mode 100644 index 0000000000..92ece27c14 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/index.mdx @@ -0,0 +1,142 @@ +import { Meta, Unstyled } from "@storybook/addon-docs/blocks" +import { FeatureGrid } from "~/docs/components/FeatureGrid" +import { + LayersFront, + BookOpen, + Marketplace, + Code, + CheckCircle, + Person, + Pencil, +} from "@/icons/app" + + + +# Co-creation + +Co-creation is the pattern for **building a resource together with AI, in context**. +Instead of filling a form alone, the user describes what they want, the system proposes +and drafts it, and the two refine it side by side until the resource is ready — with the +user approving every change. {/* TODO(creator): confirm the canonical one-line definition. */} + +Want to see it first? **[Try the interactive demo ↗](?path=/story/patterns-co-creation-creation-with-ai--surveys)** — co-creating a survey from a typed intent to a ready-to-publish draft. + +For open-ended assistance that does not produce a resource, use the chat surface directly +([`F0AiChat`](?path=/docs/sds-ai-f0aichat--documentation)). For creating and editing +records through deterministic forms and dialogs, use +[Data collection / CRUD patterns](?path=/docs/patterns-data-collection-crud-patterns-overview--documentation). +Co-creation sits between the two: conversational input, a real resource as output. + +## Introduction + +### Definition + +Co-creation turns creation into a conversation. A chat-enabled surface lets the user state +an intent in natural language; the system asks clarifying questions, drafts the resource, +and surfaces each proposed change as a card the user can accept or reject. The resource +stays visible and editable throughout, so the user is always co-author rather than reviewer. +{/* TODO(creator): confirm definition wording and scope. */} + +The pattern holds a few invariants, defined in full on the +[Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page: the +resource is built in a **canvas inside the chat** — never on a separate page the user is routed +to; the user **approves every change** before it is applied; and **leaving an in-progress +creation warns** before the draft is lost. + +### Purpose + +- **Lower the cost of a blank start:** an intent in plain language is enough to produce a + first usable draft, instead of an empty form. +- **Keep the human in control:** AI proposes, the user disposes — changes are explicit, + reviewable, and reversible before they are applied. +- **Stay in context:** creation happens over the surface the user came from (a data + collection, a page), not in a detached tool. +- **Compose, don't reinvent:** the pattern orchestrates existing AI building blocks so each + co-creation flow behaves consistently across the product. + +## Features + + + + + +## Anatomy + +A co-creation flow runs on a single chat-enabled +[`ApplicationFrame`](?path=/docs/patterns-applicationframe--documentation): the AI trigger +(the One switch) lives in the header and is available from any phase. The flow advances +through three phases — **Collection → Chat → Split** — described in +[Phases](?path=/docs/patterns-co-creation-phases--documentation), and follows the contracts on +the [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page (the +resource lives in a canvas inside the chat; the user approves every change; leaving warns). + +## Try the flow + +The pattern is demonstrated by an interactive mock — the **Creation with AI** example, which +co-creates a survey from a typed intent to a ready-to-publish draft. It runs full screen, so it +opens in its own canvas rather than cramped into this docs page: + + +
+ Open the interactive flow ↗ + + + +For a step-by-step walkthrough of what happens at each phase, see +[Creation with AI](?path=/docs/patterns-co-creation-creation-with-ai--documentation). diff --git a/packages/react/src/patterns/Cocreation/__stories__/mockData.tsx b/packages/react/src/patterns/Cocreation/__stories__/mockData.tsx new file mode 100644 index 0000000000..c1c6fd0258 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/mockData.tsx @@ -0,0 +1,195 @@ +// Mock data for the "Creation with AI" co-creation story — the collection +// records, filters, sortings, data adapter, and visualization backing the +// Surveys tab. Pure data; consumed by FlowContent's OneDataCollection source. + +import { File, Marketplace } from "@/icons/app" + +export type ResourceStatus = "Draft" | "Complete" | "Needs details" + +export type Resource = { + id: string + name: string + owner: string + status: ResourceStatus +} + +export const resourceFilters = { + status: { + type: "in" as const, + label: "Status", + options: { + options: [ + { value: "Draft", label: "Draft" }, + { value: "Needs details", label: "Needs details" }, + { value: "Complete", label: "Complete" }, + ], + }, + }, + owner: { + type: "in" as const, + label: "Owner", + options: { + options: [ + { value: "Alicia Keys", label: "Alicia Keys" }, + { value: "Dani Moreno", label: "Dani Moreno" }, + { value: "Marta Soler", label: "Marta Soler" }, + { value: "Nora Park", label: "Nora Park" }, + ], + }, + }, +} + +export const resourceSortings = { + name: { label: "Name" }, + owner: { label: "Owner" }, + status: { label: "Status" }, +} as const + +export const MOCK_RESOURCES: Resource[] = [ + { + id: "1", + name: "Engineering onboarding plan", + owner: "Alicia Keys", + status: "Draft", + }, + { + id: "2", + name: "Q3 performance review cycle", + owner: "Dani Moreno", + status: "Complete", + }, + { + id: "3", + name: "Product roadmap 2026", + owner: "Marta Soler", + status: "Needs details", + }, + { + id: "4", + name: "Offboarding checklist", + owner: "Nora Park", + status: "Draft", + }, +] + +export const filledDataAdapter = { + fetchData: () => Promise.resolve({ records: MOCK_RESOURCES }), +} + +export const tableVisualization = { + type: "table" as const, + options: { + columns: [ + { label: "Name", render: (item: Resource) => item.name }, + { label: "Owner", render: (item: Resource) => item.owner }, + { label: "Status", render: (item: Resource) => item.status }, + ], + }, +} + +// --------------------------------------------------------------------------- +// Templates tab — a browse view backed by a card visualization. Pure mock +// data: survey templates a user can start from, surfaced as metadata cards. +// --------------------------------------------------------------------------- + +export type Template = { + id: string + name: string + category: string + description: string + questions: number +} + +export const MOCK_TEMPLATES: Template[] = [ + { + id: "t1", + name: "Employee engagement survey", + category: "Engagement", + description: + "A 10-question pulse check covering motivation, clarity, and team dynamics.", + questions: 10, + }, + { + id: "t2", + name: "Employee Net Promoter Score (eNPS)", + category: "Engagement", + description: + "Measure loyalty and advocacy with a single-question score plus follow-ups.", + questions: 3, + }, + { + id: "t3", + name: "Post-onboarding feedback", + category: "Lifecycle", + description: "Capture new hire impressions after their first 30 days.", + questions: 8, + }, + { + id: "t4", + name: "Exit interview", + category: "Lifecycle", + description: + "Understand why people leave and surface trends across departures.", + questions: 12, + }, + { + id: "t5", + name: "Manager effectiveness", + category: "Management", + description: + "Gather upward feedback on coaching, communication, and support.", + questions: 9, + }, + { + id: "t6", + name: "Wellbeing pulse", + category: "Wellbeing", + description: + "Short check-in on workload, balance, and overall wellbeing at work.", + questions: 6, + }, +] + +export const templatesDataAdapter = { + fetchData: () => Promise.resolve({ records: MOCK_TEMPLATES }), +} + +export const templateFilters = { + category: { + type: "in" as const, + label: "Category", + options: { + options: [ + { value: "Engagement", label: "Engagement" }, + { value: "Lifecycle", label: "Lifecycle" }, + { value: "Management", label: "Management" }, + { value: "Wellbeing", label: "Wellbeing" }, + ], + }, + }, +} + +export const templateSortings = { + name: { label: "Name" }, + questions: { label: "Questions" }, +} as const + +export const cardVisualization = { + type: "card" as const, + options: { + title: (item: Template) => item.name, + description: (item: Template) => item.description, + cardProperties: [ + { + label: "Category", + icon: Marketplace, + render: (item: Template) => item.category, + }, + { + label: "Questions", + icon: File, + render: (item: Template) => `${item.questions} questions`, + }, + ], + }, +} diff --git a/packages/react/src/patterns/Cocreation/__stories__/phases.mdx b/packages/react/src/patterns/Cocreation/__stories__/phases.mdx new file mode 100644 index 0000000000..d0c9bf0ca1 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/phases.mdx @@ -0,0 +1,120 @@ +import { Meta, Unstyled } from "@storybook/addon-docs/blocks" +import { DoDonts } from "@/lib/storybook-utils/do-donts" + + + +# Phases + +Every co-creation flow moves through three phases — **Collection → Chat → Split**. They run +on one chat-enabled [`ApplicationFrame`](?path=/docs/patterns-applicationframe--documentation), +so the AI trigger in the header is available throughout. The whole flow is coordinated by a +single `phase` value: the chat's open state and visualization follow from it, staying in sync +as the user toggles the header trigger. + +## The three phases + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseWhat happensEntry / exit
+ Collection + + The starting surface — a data collection, page, or list. The user + works as usual; co-creation is one trigger away. + + Enters from the surface itself. Exits to Chat when the user opens the + AI via the header One switch (side panel) or the{" "} + Create primary button (full width). +
+ Chat + + The chat animates in so the user can describe what they want. + Clarifying questions narrow the intent before anything is drafted. + + Enters from Collection. Opens full width via the Create button, or as + a side panel via the One switch. Exits to Split once there is a + resource to show. +
+ Split + + The chat docks as the right side panel and the resource canvas fills + the center. The user reviews, edits, and refines the draft while the + conversation continues alongside it. + + Enters from Chat when a resource is created or opened. The + conversation and the canvas stay visible together until the resource + is done. +
+
+ +The interactive mock begins in **Collection**; opening the AI from the header moves it through +Chat and Split. It runs full screen, so open it in its own canvas: + + + + Open the interactive flow ↗ + + + +## Behavior + +- `phase` (`"collection" | "chat" | "split"`) is the single source of truth. The chat's + open state and visualization (side panel vs. full width) are derived from it — never set + independently — so the header trigger and the in-flow buttons can never disagree. +- The chat trigger lives in the `ApplicationFrame` header and is present in every phase, so + the user can open, collapse, or return to the conversation at any point. +- In Split, the resource lives in a canvas **inside the chat** — the flow never navigates to a + separate resource page. See the + [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) for this and + the other rules that hold across the phases. + +## Guidelines + + + + diff --git a/packages/react/src/patterns/Cocreation/__stories__/standard-flow.mdx b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.mdx new file mode 100644 index 0000000000..f2440fdbc2 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.mdx @@ -0,0 +1,165 @@ +import { Canvas, Meta, Unstyled } from "@storybook/addon-docs/blocks" +import { DoDonts } from "@/lib/storybook-utils/do-donts" + +import * as Stories from "./standard-flow.stories" + + + +# Standard flow + +The rules every co-creation flow follows. They define how a resource is created, how the user +moves in and out of the flow, and how the in-chat cards behave as the AI iterates. The +[Phases](?path=/docs/patterns-co-creation-phases--documentation) page describes the stages; +this page describes the contracts that hold across them. + +## Resources are created in the canvas, inside the chat + +A co-created resource is built and edited in a **canvas that opens inside the chat** — never on +a separate page the user is routed to. Once there is a draft, the chat docks to the side and the +canvas fills the center (the [Split](?path=/docs/patterns-co-creation-phases--documentation) +phase), so the conversation and the resource stay together while the user refines it. + +The user opens and closes the canvas from the resource's in-chat card; they are never navigated +away to reach it. Keeping creation in the canvas is what lets the AI and the user stay in one +shared context from intent to finished resource. + +See it in the interactive mock (runs full screen, opens in its own canvas): + + + + Open the interactive flow ↗ + + + +## Leaving an in-progress creation warns the user + +If the user does something that would **leave an in-progress creation** — following a link out, +switching to an unrelated area — confirm before letting them go, so they don't lose the draft or +the conversation. The flow does this with a "Leave creation?" confirmation. + +This is a **general rule**, not specific to co-creation: any flow holding unsaved, in-progress +work should warn before discarding it. {/* TODO(creator): promote this to a general DS principle and link it here. */} + +Closing or collapsing the chat panel is **not** leaving — the flow is preserved and reopens +where it was, so that action does not warn. Reserve the confirmation for navigation that would +actually abandon the work. + + + +## Resource cards across AI iterations + +Each resource the AI creates posts an **in-chat card** — built on +[`F0CardHorizontal`](?path=/docs/experimental-cardhorizontal--documentation) — that is the user's handle to +open and close that resource in the canvas. As the AI iterates, the cards change state so the +chat stays an accurate, navigable history. + +- **Active vs. inactive** — a card is _active_ when its resource is the one currently shown in + the canvas (it offers Close and reads as selected). Only one card is active at a time; opening + another resource deactivates the previous one. +- **Live vs. superseded** — when the AI applies an accepted change, it posts a **fresh card** for + the same resource. The new card is _live_ (interactive); the previous one is _superseded_ — it + drops its Open/Close action and dims, but stays in the chat so the timeline is preserved. +- **Proposals** — a proposed change appears as a pending + [`F0CardHorizontal`](?path=/docs/experimental-cardhorizontal--documentation) confirm/reject card + with accept (✓) and reject (✗) actions. Deciding it resolves the card to an outcome state. + Accepting runs the + [processing overlay](?path=/docs/patterns-co-creation-building-blocks--documentation), applies + the change, and posts the new live resource card that supersedes the prior one. + +The resource cards below show the lifecycle: the live card carries a module avatar and an +Open/Close toggle, while the superseded card — replaced by a newer one for the same resource — +drops the toggle and fades to 50%, staying in the chat as history: + + + +Proposals resolve the same way. In the conversation below, the two earlier proposal cards are +resolved — accepted keeps its outcome icon, rejected is struck through and dimmed — while the +last card is still active, awaiting the user's accept (✓) or reject (✗): + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CardStateAppearanceInteractive
Resource card + Active + + Its resource is open in the canvas — offers Close, highlighted border. + Yes — one card active at a time
Resource card + Live (not open) + + Latest card for the resource, not currently shown — offers Open. + Yes
Resource card + Superseded + + Replaced by a newer card — Open/Close removed, dimmed, kept in + history. + No
Proposal card + Pending + Accept (✓) / reject (✗) actions on the trailing edge.Yes
Proposal card + Resolved + Outcome icon (Accepted / Rejected) in place of the actions.No
+
+ + diff --git a/packages/react/src/patterns/Cocreation/__stories__/standard-flow.stories.tsx b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.stories.tsx new file mode 100644 index 0000000000..188c8d9690 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { type ReactNode } from "react" + +import { F0CardHorizontal } from "@/experimental/F0CardHorizontal" +import { Check, Cross } from "@/icons/app" + +// Illustrative-only stories for the Co-creation "Standard flow" docs page. They +// render compact mocks (not the full-screen flow) so they can be embedded inline +// via in standard-flow.mdx. Hidden from the sidebar; the page itself is +// the manual MDX, so opt out of the globally enabled autodocs. +const meta = { + title: "Co-creation/Standard flow", + tags: ["!autodocs"], + parameters: { + layout: "padded", + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Both the resource cards and the proposal cards are F0CardHorizontal rows that +// share this module avatar, so they line up consistently in the chat and keep +// the status icon / actions aligned on the avatar's centre line (a row with no +// avatar centres differently). See the canonical states in Components / Card +// Horizontal. +const PROPOSAL_AVATAR = { type: "module", module: "engagement" } as const + +// A plain assistant line in the mock conversation. +function AssistantLine({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ) +} + +// A right-aligned user message bubble in the mock conversation. +function UserLine({ children }: { children: ReactNode }) { + return ( +
+ + {children} + +
+ ) +} + +/** + * Mock of the in-chat resource card in its two states, built on `F0CardHorizontal`: + * the live card carries a module avatar and an Open/Close button; a superseded + * card — replaced by a newer one for the same resource — drops the button, stops + * responding to clicks, and fades to 50% opacity while staying in the chat + * history. Embedded in the Standard flow docs via . + */ +export const ResourceCards: Story = { + tags: ["no-sidebar"], + render: () => ( +
+ I've created your survey. + {/* Superseded: faded + non-interactive, no action button. */} +
+ +
+ + Done — I've added the question to your survey. + + {/* Active (open in the canvas): the button reads "Close". */} + {}, + variant: "outline", + }} + /> +
+ ), +} + +/** + * Mock of an in-chat conversation showing the proposal cards in both states: + * the earlier cards are resolved — accepted keeps its outcome icon, rejected is + * struck through (`inactive`) and dimmed — and the last card is active, still + * awaiting the user's accept (✓) / reject (✗). Built on `F0CardHorizontal` so the + * states match the canonical Components / Card Horizontal stories. Embedded in + * the Standard flow docs via . + */ +export const HilConversation: Story = { + tags: ["no-sidebar"], + render: () => ( +
+ + Good idea — here's a change I'd suggest. Review it below. + + + Can you drop the demographics section? + Here's that change. + + Add a rating question instead. + Here's the change — apply it? + {} }} + confirmAction={{ label: "Apply", onClick: () => {} }} + /> +
+ ), +} diff --git a/packages/react/src/patterns/Cocreation/__stories__/survey-mocks.ts b/packages/react/src/patterns/Cocreation/__stories__/survey-mocks.ts new file mode 100644 index 0000000000..b2715ec24d --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/survey-mocks.ts @@ -0,0 +1,260 @@ +// Survey split-canvas mock for the "Creation with AI" co-creation story — a +// representative Survey reproduced with the real SurveyAnsweringForm (inline, +// read-only). Modeled on the SurveyAnsweringForm story's `sampleElements`, +// themed to the "Employee engagement survey" card. + +import { makeMockTranscribe } from "@/lib/storybook-utils/ai-mocks" +import type { SurveyAnswers } from "@/sds/surveys/SurveyAnsweringForm" +import { + getDefaultParamsForQuestionType, + getDefaultQuestionTypeToAdd, + getNewElementId, +} from "@/sds/surveys/SurveyFormBuilder/lib" +import type { + QuestionElement, + SurveyFormBuilderElement, +} from "@/sds/surveys/SurveyFormBuilder/types" + +/** + * The starting point of a blank survey: a single empty section holding one + * default (text) question — exactly what `SurveyFormBuilder` seeds on its first + * render for an empty survey. We build it here so a freshly-created "empty + * survey" already carries this first section/question as durable store state, + * rather than relying on the form builder's render-time auto-seed. + */ +export const makeInitialSurveyElements = (): SurveyFormBuilderElement[] => { + const questionType = getDefaultQuestionTypeToAdd() + return [ + { + type: "section", + section: { + id: getNewElementId("section"), + title: "", + questions: [ + { + id: getNewElementId("question"), + title: "", + description: "", + type: questionType, + required: true, + ...getDefaultParamsForQuestionType(questionType), + } as QuestionElement, + ], + }, + }, + ] +} + +export const SURVEY_ELEMENTS: SurveyFormBuilderElement[] = [ + { + type: "section", + section: { + id: "section-intro", + title: "Before you start", + description: + "A couple of quick details to set the context for your responses.", + questions: [ + { + id: "q-name", + title: "What is your name?", + description: "Responses stay anonymous in aggregate reporting.", + type: "text" as const, + }, + { + id: "q-review-period", + title: "Which period is this pulse check for?", + type: "date" as const, + }, + ], + }, + }, + { + type: "section", + section: { + id: "section-motivation", + title: "Motivation & engagement", + description: + "How energised and supported you feel in your day-to-day work.", + questions: [ + { + id: "q-engagement-rating", + title: "How motivated do you feel at work right now?", + description: "1 is not at all, 5 is highly motivated.", + type: "rating" as const, + options: [ + { value: 1, label: "1" }, + { value: 2, label: "2" }, + { value: 3, label: "3" }, + { value: 4, label: "4" }, + { value: 5, label: "5" }, + ], + required: true, + }, + { + id: "q-energises", + title: "What energises you most in your role?", + description: "Share the work that makes a good day.", + type: "longText" as const, + }, + { + id: "q-drivers", + title: "Which factors drive your motivation?", + description: "Select all that apply.", + type: "multi-select" as const, + options: [ + { value: "growth", label: "Growth & learning" }, + { value: "recognition", label: "Recognition" }, + { value: "autonomy", label: "Autonomy" }, + { value: "impact", label: "Impact of the work" }, + { value: "team", label: "The team" }, + ], + }, + { + id: "q-team", + title: "Which team are you on?", + type: "dropdown-single" as const, + datasetKey: "teams", + required: true, + }, + ], + }, + }, + { + type: "section", + section: { + id: "section-clarity", + title: "Team & clarity", + description: "How clear and connected you feel with your team and goals.", + questions: [ + { + id: "q-clarity", + title: "How clear are your goals for this quarter?", + type: "select" as const, + options: [ + { value: "very-clear", label: "Very clear" }, + { value: "somewhat", label: "Somewhat clear" }, + { value: "unclear", label: "Unclear" }, + ], + required: true, + }, + { + id: "q-collaborators", + title: "Who do you collaborate with most closely?", + type: "dropdown-multi" as const, + datasetKey: "employees", + }, + { + id: "q-one-on-ones", + title: "How many 1:1s did you have with your manager this month?", + type: "numeric" as const, + }, + { + id: "q-acknowledge", + title: "Acknowledgement", + description: "Please confirm before submitting.", + type: "checkbox" as const, + label: "My responses reflect how I genuinely feel.", + required: true, + }, + ], + }, + }, +] + +// Survey seeded by the "Employee NPS" welcome card. It opens with a dedicated, +// blocked initial section holding the predefined eNPS question: `locked` on the +// section strips its edit menu, disables its fields and removes its drag handle +// (and, by inheritance, blocks the question inside it too), while its title and +// description explain why it's fixed and a "Locked" tag marks it. A following +// "Your feedback" section holds the ordinary editable questions, so the +// contrast (blocked vs editable) is visible across sections. +export const NPS_SURVEY_ELEMENTS: SurveyFormBuilderElement[] = [ + { + type: "section", + section: { + id: "section-enps-score", + locked: true, + title: "Predefined eNPS section", + description: + "This section powers your Employee NPS score, so it can't be edited, moved, or removed.", + questions: [ + { + id: "q-enps", + title: + "How likely are you to recommend [Company] as a place to work?", + description: "0 is not at all likely, 10 is extremely likely.", + type: "rating" as const, + options: Array.from({ length: 11 }, (_, value) => ({ + value, + label: String(value), + })), + required: true, + lockedNote: { + description: + "The standard eNPS question — its wording and 0–10 scale are fixed so scores stay comparable over time.", + }, + }, + ], + }, + }, + { + type: "section", + section: { + id: "section-enps-feedback", + title: "Your feedback", + description: + "A little context behind your score — these are yours to edit.", + questions: [ + { + id: "q-enps-reason", + title: "What's the main reason for your score?", + description: "Tell us what's behind the number you picked.", + type: "longText" as const, + }, + { + id: "q-enps-improve", + title: "What's one thing that would improve your experience here?", + type: "longText" as const, + }, + { + id: "q-enps-team", + title: "Which team are you on?", + type: "dropdown-single" as const, + datasetKey: "teams", + }, + ], + }, + }, +] + +export const SURVEY_DEFAULT_VALUES: Partial = { + "q-name": { type: "text", value: "Jordan Lee" }, + "q-engagement-rating": { type: "rating", value: 4 }, + "q-drivers": { type: "multi-select", value: ["growth", "team"] }, + "q-team": { type: "dropdown-single", value: "engineering" }, + "q-clarity": { type: "select", value: "very-clear" }, +} + +// Spoken-style refinement requests the user dictates into the chat composer. +// Themed to the engagement / eNPS surveys above and centred on the two survey +// mechanics the flow is about — follow-up questions and the triggers (conditional +// logic) that surface them — so the streamed transcript reads as a natural "make +// my survey smarter" ask rather than the generic dictation pool in ai-mocks. +const SURVEY_DICTATION_TRANSCRIPTS = [ + "Add a follow-up question after the motivation rating so that whenever someone scores three or lower we ask them what's getting in the way and how we could support them better.", + "If a respondent says their goals for the quarter are unclear, trigger an open text follow-up asking which goals they'd like more clarity on and who should help define them.", + "Set up a branch where people who aren't happy with management get a couple of follow-ups about communication and feedback, while everyone else skips straight to the next section.", + "Whenever someone signals they might leave in the next six months, show a follow-up asking what would make them stay and flag that response for the people team to review.", + "Add a work-life balance rating, and if the score comes in below average trigger a follow-up that asks whether it's mostly workload, scheduling, or after-hours messages.", + "For the eNPS question, add a trigger so detractors who give a six or lower get a follow-up asking for the single most important thing we should fix first.", +] as const + +/** + * Survey-contextual voice dictation for the "Creation with AI" co-creation flow: + * streams a spoken-style survey-refinement request (follow-up questions and + * triggers) into the composer so the user can review it and send. Wired to the + * chat via the ApplicationFrame `ai.onTranscribe` prop. + */ +export const mockSurveyTranscribe = makeMockTranscribe( + SURVEY_DICTATION_TRANSCRIPTS +) diff --git a/packages/react/src/patterns/Cocreation/__stories__/tab-configs.ts b/packages/react/src/patterns/Cocreation/__stories__/tab-configs.ts new file mode 100644 index 0000000000..bcc0f17e41 --- /dev/null +++ b/packages/react/src/patterns/Cocreation/__stories__/tab-configs.ts @@ -0,0 +1,26 @@ +// Per-tab configuration for the "Creation with AI" co-creation story — the +// resource cards surfaced for the Surveys collection tab. Pure data; the story +// wires it up via TabConfigProvider. + +export type TabConfig = { + cards: [ + { title: string; description: string }, + { title: string; description: string }, + ] +} + +export const TAB_CONFIGS: Record = { + surveys: { + cards: [ + { + title: "Employee engagement survey", + description: + "A 10-question pulse check covering motivation, clarity, and team dynamics.", + }, + { + title: "Post-onboarding feedback survey", + description: "Captures new hire impressions after their first 30 days.", + }, + ], + }, +} diff --git a/packages/react/src/patterns/ResourceHeader/index.tsx b/packages/react/src/patterns/ResourceHeader/index.tsx index 0ab0b8f37d..c5f1272dc9 100644 --- a/packages/react/src/patterns/ResourceHeader/index.tsx +++ b/packages/react/src/patterns/ResourceHeader/index.tsx @@ -19,6 +19,7 @@ type Props = {} & Pick< | "deactivated" | "metadataRowGap" | "showBottomBorder" + | "onClose" > const _ResourceHeader = ({ @@ -33,6 +34,7 @@ const _ResourceHeader = ({ deactivated, metadataRowGap, showBottomBorder, + onClose, }: Props) => { return ( ) } diff --git a/packages/react/src/sds/ai/F0AiChat/F0AiChat.tsx b/packages/react/src/sds/ai/F0AiChat/F0AiChat.tsx index 40645ab867..c9a70655d5 100644 --- a/packages/react/src/sds/ai/F0AiChat/F0AiChat.tsx +++ b/packages/react/src/sds/ai/F0AiChat/F0AiChat.tsx @@ -10,11 +10,7 @@ import { useI18n } from "@/lib/providers/i18n" import { SidebarWindow } from "./components/layout/ChatWindow" import { useRevealOnChange } from "./hooks/useRevealOnChange" import { AiChatStateProvider, useAiChat } from "./providers/AiChatStateProvider" -import { - AiChatProviderProps, - type VisualizationMode, - type WelcomeScreenSuggestion, -} from "./types" +import { AiChatProviderProps, type WelcomeScreenSuggestion } from "./types" /** * Slot composition for the F0 AI chat shell. F0 ships the shell + UI @@ -118,15 +114,16 @@ const F0AiChatComponent = ({ } = useAiChat() const translations = useI18n() - // Mode-change reveal: switching between sidepanel / fullscreen / canvas - // changes the whole content layout. Rather than animating each element into - // place (busy), hide the content the instant the mode flips and reveal it - // already settled with a soft fade. Hold ≈ the chat window's resize - // animation (see ApplicationFrame: ~0.15s entering, ~0.4s exiting). + // Mode-change reveal: only fullscreen transitions change the layout enough to + // warrant a re-fade. Sidepanel + canvas are treated as one "docked" state, so + // opening/closing the canvas beside the docked chat doesn't re-fade it; + // fullscreen still reveals. Hold ≈ the chat window's resize animation (see + // ApplicationFrame: ~0.15s entering, ~0.4s exiting). + const revealValue: "docked" | "fullscreen" = + visualizationMode === "fullscreen" ? "fullscreen" : "docked" const { motionProps: contentReveal } = useRevealOnChange( - visualizationMode, - (prev: VisualizationMode, next: VisualizationMode) => - next === "fullscreen" ? 220 : prev === "fullscreen" ? 460 : 260 + revealValue, + (_prev, next) => (next === "fullscreen" ? 220 : 460) ) const reducedMotion = useReducedMotion() diff --git a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockAiChatRuntime.tsx b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockAiChatRuntime.tsx index 58fa6fb686..81a51d64c7 100644 --- a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockAiChatRuntime.tsx +++ b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockAiChatRuntime.tsx @@ -9,6 +9,11 @@ import { } from "react" import { type ChatThread } from "../../../F0AiChatHistory" +import type { + ClarifyingOption, + ClarifyingQuestionState, + ClarifyingSelectionMode, +} from "../../../F0ClarifyingPanel" import { type F0Message } from "../../types" @@ -29,16 +34,89 @@ import { pickRandomResponse, pickRandomThinkingSteps } from "./mockPhrases" * runtime. Factorial's production adapter mirrors this shape but reads * messages from CopilotKit instead. */ +/** A single step within a (possibly multi-step) clarifying flow. */ +export type ClarifyingStep = { + question: string + options: ClarifyingOption[] + selectionMode?: ClarifyingSelectionMode + optional?: boolean + allowCustomAnswer?: boolean +} + +/** + * Config for a clarifying flow rendered in the composer (the + * `F0ClarifyingPanel` slot). One or more `steps` are walked consecutively in a + * single panel: picking an answer advances to the next step (the header shows a + * "X of Y" counter and a back arrow), and the final step submits. The runtime + * owns the per-step selection state; `onConfirm` receives the picked option + * labels for every step, in order, once the whole flow resolves. + * + * A single-step flow (one entry in `steps`) behaves like the old one-shot + * question — no counter, no navigation, just a Submit button. + */ +export type ClarifyingConfig = { + steps: ClarifyingStep[] + onConfirm?: (answersByStep: string[][]) => void +} + +/** Per-step interaction state tracked while a clarifying flow is open. */ +type ClarifyingInteraction = { + selectedIds: string[] + customText: string + isCustomActive: boolean +} + +const EMPTY_CLARIFYING_INTERACTION: ClarifyingInteraction = { + selectedIds: [], + customText: "", + isCustomActive: false, +} + +const getClarifyingInteraction = ( + map: Record, + index: number +): ClarifyingInteraction => map[index] ?? EMPTY_CLARIFYING_INTERACTION + export type MockAiChatRuntime = { messages: F0Message[] inProgress: boolean sendMessage: (text: string, options?: { replyQuote?: string }) => void + /** Sends a user message and shows thinking steps, but emits no text response. */ + sendMessageWithThinkingOnly: (text: string) => void appendMessages: ( messages: { role: "user" | "assistant"; content: string }[], options?: { persist?: boolean } ) => void + /** + * Appends an assistant message that renders a custom card/component (via the + * message's `generativeUI` slot) instead of markdown text. + */ + appendCard: (render: () => ReactNode) => void + /** Swaps the scripted assistant responses and restarts from the first turn. */ + setScript: (script: string[]) => void + /** + * Arms a one-shot handler for the NEXT user message: the user's text is still + * posted, but instead of streaming a reply the handler runs (e.g. to kick off + * a scripted flow). Cleared after it fires. Pass `null` to disarm. + */ + setUserMessageInterceptor: ( + interceptor: ((text: string) => void) | null + ) => void clear: () => void + // ── Clarifying question ───────────────────────────────────────── + /** + * The active clarifying question (rendered by the connected chat input as a + * `F0ClarifyingPanel`), or `null` when none is in progress. + */ + clarifyingQuestion: ClarifyingQuestionState | null + /** + * Opens a clarifying flow in the composer. Pass one step for a one-shot + * question, or several to walk them consecutively in a single panel (with a + * step counter + back arrow) before a final submit. + */ + startClarifying: (config: ClarifyingConfig) => void + // ── Chat history ──────────────────────────────────────────────── currentThreadTitle: string | null /** Id of the thread currently loaded (null on a fresh / new chat). */ @@ -237,12 +315,18 @@ export type MockAiChatRuntimeProviderProps = { * state. */ seedThreads?: ChatThread[] + /** + * Optional scripted assistant responses used in order, one per text reply. + * Falls back to a random phrase when the script is exhausted or absent. + */ + script?: string[] } export const MockAiChatRuntimeProvider = ({ children, seedMessages, seedThreads, + script, }: MockAiChatRuntimeProviderProps) => { const [messages, setMessages] = useState(seedMessages ?? []) const [inProgress, setInProgress] = useState(false) @@ -259,6 +343,15 @@ export const MockAiChatRuntimeProvider = ({ ) const timersRef = useRef[]>([]) const intervalsRef = useRef[]>([]) + const scriptRef = useRef(script ?? []) + const scriptTurnRef = useRef(0) + const [clarifyingConfig, setClarifyingConfig] = + useState(null) + const [clarifyingStepIndex, setClarifyingStepIndex] = useState(0) + const [clarifyingInteractions, setClarifyingInteractions] = useState< + Record + >({}) + const interceptorRef = useRef<((text: string) => void) | null>(null) // Allow late-arriving seed messages (story decorators that prefill async). useEffect(() => { @@ -279,25 +372,19 @@ export const MockAiChatRuntimeProvider = ({ return () => clearTimers() }, [clearTimers]) - const streamAssistantResponse = useCallback(() => { - const thinkingSteps = pickRandomThinkingSteps(3) - const response = pickRandomResponse() - const thinkingId = nextId() - const assistantId = nextId() - - setInProgress(true) - - // Append the thinking message + first step - const startThinking = setTimeout(() => { + // Emits thinking-step messages and returns the total duration in ms. + // Caller is responsible for what happens after thinking completes. + const emitThinkingSteps = useCallback( + (thinkingSteps: string[], baseId: string): number => { setMessages((prev) => [ ...prev, { - id: thinkingId, + id: baseId, role: "assistant", content: "", toolCalls: [ { - id: `${thinkingId}_tc0`, + id: `${baseId}_tc0`, type: "function", function: { name: "orchestratorThinking", @@ -308,7 +395,6 @@ export const MockAiChatRuntimeProvider = ({ }, ]) - // Add subsequent thinking steps every THINKING_STEP_MS thinkingSteps.slice(1).forEach((step, i) => { const t = setTimeout( () => { @@ -320,7 +406,7 @@ export const MockAiChatRuntimeProvider = ({ content: "", toolCalls: [ { - id: `${assistantId}_tc${i + 1}`, + id: `${baseId}_tc${i + 1}`, type: "function", function: { name: "orchestratorThinking", @@ -336,8 +422,24 @@ export const MockAiChatRuntimeProvider = ({ timersRef.current.push(t) }) + return THINKING_STEP_MS * thinkingSteps.length + }, + [] + ) + + const streamAssistantResponse = useCallback(() => { + const thinkingSteps = pickRandomThinkingSteps(3) + const response = + scriptRef.current[scriptTurnRef.current++] ?? pickRandomResponse() + const thinkingId = nextId() + const assistantId = nextId() + + setInProgress(true) + + const startThinking = setTimeout(() => { + const totalThinkingMs = emitThinkingSteps(thinkingSteps, thinkingId) + // Once thinking is done, open the assistant text message and stream chars. - const totalThinkingMs = THINKING_STEP_MS * thinkingSteps.length const startText = setTimeout(() => { setMessages((prev) => [ ...prev, @@ -367,7 +469,7 @@ export const MockAiChatRuntimeProvider = ({ }, THINKING_DELAY_MS) timersRef.current.push(startThinking) - }, []) + }, [emitThinkingSteps]) const sendMessage = useCallback( (text: string, options?: { replyQuote?: string }) => { @@ -382,11 +484,46 @@ export const MockAiChatRuntimeProvider = ({ replyQuote: options?.replyQuote, }, ]) + // A one-shot interceptor short-circuits the canned reply so a scripted + // flow can take over from the user's first message. + const interceptor = interceptorRef.current + if (interceptor) { + interceptorRef.current = null + interceptor(trimmed) + return + } streamAssistantResponse() }, [streamAssistantResponse] ) + const sendMessageWithThinkingOnly = useCallback( + (text: string) => { + const trimmed = text.trim() + if (!trimmed) return + setMessages((prev) => [ + ...prev, + { id: nextId(), role: "user", content: trimmed }, + ]) + + const thinkingSteps = pickRandomThinkingSteps(3) + const thinkingId = nextId() + + setInProgress(true) + + const startThinking = setTimeout(() => { + const totalThinkingMs = emitThinkingSteps(thinkingSteps, thinkingId) + const done = setTimeout(() => { + setInProgress(false) + }, totalThinkingMs) + timersRef.current.push(done) + }, THINKING_DELAY_MS) + + timersRef.current.push(startThinking) + }, + [emitThinkingSteps] + ) + const appendMessages = useCallback( (msgs) => { setMessages((prev) => [ @@ -401,6 +538,151 @@ export const MockAiChatRuntimeProvider = ({ [] ) + const appendCard = useCallback((render) => { + setMessages((prev) => [ + ...prev, + { id: nextId(), role: "assistant", content: "", generativeUI: render }, + ]) + }, []) + + const setScript = useCallback((next) => { + scriptRef.current = next + scriptTurnRef.current = 0 + }, []) + + const setUserMessageInterceptor = useCallback< + MockAiChatRuntime["setUserMessageInterceptor"] + >((interceptor) => { + interceptorRef.current = interceptor + }, []) + + const startClarifying = useCallback( + (config) => { + setClarifyingStepIndex(0) + setClarifyingInteractions({}) + setClarifyingConfig(config) + }, + [] + ) + + const closeClarifying = useCallback(() => { + setClarifyingConfig(null) + setClarifyingStepIndex(0) + setClarifyingInteractions({}) + }, []) + + // Rebuilt each render so the option-toggle / confirm closures always read the + // latest selection. The runtime owns the per-step selection state and the + // current step index; `confirm` advances through the steps and, on the final + // one, fires `onConfirm` with the picked labels for every step in order. + const clarifyingQuestion: ClarifyingQuestionState | null = (() => { + if (!clarifyingConfig) return null + const steps = clarifyingConfig.steps + const stepIndex = clarifyingStepIndex + const step = steps[stepIndex] + if (!step) return null + + const mode = step.selectionMode ?? "single" + const interaction = getClarifyingInteraction( + clarifyingInteractions, + stepIndex + ) + + const updateInteraction = (patch: Partial) => + setClarifyingInteractions((prev) => ({ + ...prev, + [stepIndex]: { ...getClarifyingInteraction(prev, stepIndex), ...patch }, + })) + + // Collect the picked labels for every step, in order — single-select steps + // fall back to their custom answer when nothing is selected; multi-select + // steps append the custom answer when it's active and non-empty. + const buildAnswers = (): string[][] => + steps.map((s, i) => { + const inter = getClarifyingInteraction(clarifyingInteractions, i) + const labels = s.options + .filter((o) => inter.selectedIds.includes(o.id)) + .map((o) => o.label) + const isSingle = (s.selectionMode ?? "single") === "single" + const includeCustom = isSingle + ? inter.selectedIds.length === 0 && inter.customText.trim().length > 0 + : inter.isCustomActive && inter.customText.trim().length > 0 + if (includeCustom) labels.push(inter.customText.trim()) + return labels + }) + + const resolve = () => { + const answers = buildAnswers() + const onConfirm = clarifyingConfig.onConfirm + closeClarifying() + onConfirm?.(answers) + } + + const isFinalStep = stepIndex === steps.length - 1 + + return { + currentStep: { + question: step.question, + options: step.options, + selectionMode: mode, + optional: step.optional ?? false, + allowCustomAnswer: step.allowCustomAnswer ?? false, + selectedOptionIds: interaction.selectedIds, + customAnswerText: interaction.customText || undefined, + isCustomAnswerActive: interaction.isCustomActive, + }, + currentStepIndex: stepIndex, + totalSteps: steps.length, + toggleOption: (optionId) => { + if (mode === "single") { + updateInteraction({ selectedIds: [optionId] }) + } else { + setClarifyingInteractions((prev) => { + const current = getClarifyingInteraction( + prev, + stepIndex + ).selectedIds + const next = current.includes(optionId) + ? current.filter((id) => id !== optionId) + : [...current, optionId] + return { + ...prev, + [stepIndex]: { + ...getClarifyingInteraction(prev, stepIndex), + selectedIds: next, + }, + } + }) + } + }, + confirm: () => { + if (!isFinalStep) { + setClarifyingStepIndex(stepIndex + 1) + } else { + resolve() + } + }, + skip: () => { + if (!step.optional) return + if (!isFinalStep) { + setClarifyingStepIndex(stepIndex + 1) + } else { + resolve() + } + }, + cancel: closeClarifying, + back: () => setClarifyingStepIndex((i) => Math.max(0, i - 1)), + setCustomAnswerText: (text) => updateInteraction({ customText: text }), + setCustomAnswerActive: (active) => + updateInteraction({ isCustomActive: active }), + activateCustomAnswer: () => { + const patch: Partial = { isCustomActive: true } + if (mode === "single") patch.selectedIds = [] + updateInteraction(patch) + }, + } + })() + const clear = useCallback(() => { clearTimers() setMessages([]) @@ -408,6 +690,11 @@ export const MockAiChatRuntimeProvider = ({ setCurrentThreadTitle(null) setCurrentThreadId(null) setIsLoadingThread(false) + scriptTurnRef.current = 0 + setClarifyingConfig(null) + setClarifyingStepIndex(0) + setClarifyingInteractions({}) + interceptorRef.current = null }, [clearTimers]) // ── Chat history ──────────────────────────────────────────────── @@ -482,8 +769,14 @@ export const MockAiChatRuntimeProvider = ({ messages, inProgress, sendMessage, + sendMessageWithThinkingOnly, appendMessages, + appendCard, + setScript, + setUserMessageInterceptor, clear, + clarifyingQuestion, + startClarifying, currentThreadTitle, currentThreadId, isLoadingThread, diff --git a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedChatInput.tsx b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedChatInput.tsx index c75223856b..f5e60be281 100644 --- a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedChatInput.tsx +++ b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedChatInput.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef } from "react" import { F0AiChatTextArea } from "../../../F0AiChatTextArea" import { type F0AiChatTextAreaSubmitPayload } from "../../../F0AiChatTextArea/types" +import { F0ClarifyingPanel } from "../../../F0ClarifyingPanel" import { useAiChat } from "../../providers/AiChatStateProvider" import type { WelcomeScreenSuggestion, @@ -23,6 +24,7 @@ export const MockConnectedChatInput = () => { sendMessage, isLoadingThread, currentThreadTitle, + clarifyingQuestion, } = useMockAiChatRuntime() const { placeholders, @@ -39,6 +41,7 @@ export const MockConnectedChatInput = () => { visualizationMode, creditWarning, welcomeScreenSuggestions, + welcomeScreenCards, tracking, openGame, } = useAiChat() @@ -102,6 +105,15 @@ export const MockConnectedChatInput = () => { fullscreen={fullscreen} welcomeScreenSuggestions={welcomeScreenSuggestions} onSuggestionClick={handleSuggestionClick} + welcomeScreenCards={welcomeScreenCards} + clarifyingUI={ + clarifyingQuestion ? ( + + ) : undefined + } /> ) } diff --git a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedMessagesContainer.tsx b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedMessagesContainer.tsx index e9c436351a..1d5cd9fed2 100644 --- a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedMessagesContainer.tsx +++ b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/MockConnectedMessagesContainer.tsx @@ -68,9 +68,10 @@ export const MockConnectedMessagesContainer = ({ [messages] ) - const turns = useMemo(() => { + const turns = useMemo(() => { const rawTurns = convertMessagesToTurns(filteredMessages) - return rawTurns.map((turnMessages, turnIndex) => { + + const built: RenderableTurn[] = rawTurns.map((turnMessages, turnIndex) => { const { turnIsComplete, showActivityIndicator } = analyzeTurn( turnMessages, turnIndex, @@ -135,6 +136,8 @@ export const MockConnectedMessagesContainer = ({ feedback: feedbackEntry, } }) + + return built }, [filteredMessages, inProgress]) const onReplyQuote = useCallback( diff --git a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/index.ts b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/index.ts index 974f37dd03..71485eddc7 100644 --- a/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/index.ts +++ b/packages/react/src/sds/ai/F0AiChat/__stories__/_mock/index.ts @@ -4,6 +4,8 @@ export { DEFAULT_MOCK_THREADS, type MockAiChatRuntime, type MockAiChatRuntimeProviderProps, + type ClarifyingConfig, + type ClarifyingStep, } from "./MockAiChatRuntime" export { MockConnectedMessagesContainer } from "./MockConnectedMessagesContainer" export { MockConnectedChatInput } from "./MockConnectedChatInput" diff --git a/packages/react/src/sds/ai/F0AiChatTextArea/__stories__/F0AiChatTextArea.stories.tsx b/packages/react/src/sds/ai/F0AiChatTextArea/__stories__/F0AiChatTextArea.stories.tsx index 1174d60984..bb1ba06a55 100644 --- a/packages/react/src/sds/ai/F0AiChatTextArea/__stories__/F0AiChatTextArea.stories.tsx +++ b/packages/react/src/sds/ai/F0AiChatTextArea/__stories__/F0AiChatTextArea.stories.tsx @@ -4,7 +4,13 @@ import { useRef, useState } from "react" import { F0AiChatTextArea } from "../F0AiChatTextArea" import type { F0AiChatTextAreaSubmitPayload } from "../types" -import { File, Marketplace } from "@/icons/app" +import { + ChartVerticalBars, + File, + Marketplace, + Pencil, + Search, +} from "@/icons/app" import { mockTranscribe } from "@/lib/storybook-utils/ai-mocks" import { F0ClarifyingPanel } from "../../F0ClarifyingPanel" @@ -19,6 +25,7 @@ import type { PersonProfile, TranscribeFn, UploadedFile, + WelcomeScreenSuggestion, } from "../../F0AiChat/types" const ROTATING_PLACEHOLDERS = [ @@ -107,7 +114,66 @@ const WELCOME_CARDS: F0AiChatWelcomeCard[] = [ title: "Templates", description: "Browse pre-made surveys", // No message: a templates card triggers a non-prompt behavior, handled by - // the host via its id. + // the host in its `onClick`. + }, +] + +// Welcome suggestions: grouped outline buttons shown ABOVE the composer. Each +// group opens a popover of starter prompts; clicking one sends its `prompt` +// straight to the AI (contrast with welcome cards, which fire a host action). +const WELCOME_SUGGESTIONS: WelcomeScreenSuggestion[] = [ + { + icon: ChartVerticalBars, + label: "Analyze", + items: [ + { + title: "April leave and overtime summary", + prompt: + "Give me a breakdown of leave taken and overtime worked across the company in April, grouped by department.", + }, + { + title: "Current gross salary by employee", + prompt: + "List the current gross salary of every active employee, sorted from highest to lowest.", + }, + { + title: "Headcount evolution by department", + prompt: + "Plot headcount evolution by department over the last twelve months.", + }, + ], + }, + { + icon: Search, + label: "Find", + items: [ + { + title: "Who's out of office this week?", + prompt: + "List every employee on time-off or sick leave between today and the end of the week.", + }, + { + title: "Engineers based in Barcelona", + prompt: + "Find all employees in Engineering whose office location is Barcelona.", + }, + ], + }, + { + icon: Pencil, + label: "Create", + items: [ + { + title: "Draft a Senior Backend job description", + prompt: + "Draft a job description for a Senior Backend Engineer focused on distributed systems.", + }, + { + title: "Compose an offboarding email template", + prompt: + "Compose an offboarding email template covering return-of-equipment steps and the HR exit form.", + }, + ], }, ] @@ -155,6 +221,7 @@ type WrapperProps = { creditWarning?: AiChatCreditWarning disclaimer?: AiChatDisclaimer footer?: React.ReactNode + welcomeScreenSuggestions?: WelcomeScreenSuggestion[] welcomeScreenCards?: F0AiChatWelcomeCard[] isWelcomeScreen?: boolean fullscreen?: boolean @@ -172,6 +239,7 @@ const Wrapper = ({ creditWarning, disclaimer, footer, + welcomeScreenSuggestions, welcomeScreenCards, isWelcomeScreen, fullscreen, @@ -193,21 +261,25 @@ const Wrapper = ({ const composerRef = useRef(null) - // The host wires each card's `onClick`. Cards carrying a `message` send it as - // a prompt; message-less cards (e.g. "Templates") do something else. - const cardsWithHandlers = welcomeScreenCards?.map((card) => ({ - ...card, - onClick: () => { - if (card.message) { - setSubmissions((prev) => [ - ...prev, - { text: card.message!, files: [], context: null, quote: null }, - ]) - } else { - console.log(`card clicked: ${card.id}`) - } - }, - })) + // Welcome cards now carry their own `onClick`. Branch on each card's data: + // message-bearing cards (e.g. "Empty survey") send their prompt; message-less + // cards (e.g. "Templates") do something other than send a prompt. + const cardsWithBehavior = welcomeScreenCards?.map((card) => { + const { id, message } = card + return { + ...card, + onClick: () => { + if (message) { + setSubmissions((prev) => [ + ...prev, + { text: message, files: [], context: null, quote: null }, + ]) + } else { + console.log(`card clicked: ${id}`) + } + }, + } + }) return (
@@ -232,7 +304,17 @@ const Wrapper = ({ searchPersons={searchPersons} disclaimer={disclaimer} footer={footer} - welcomeScreenCards={cardsWithHandlers} + welcomeScreenSuggestions={welcomeScreenSuggestions} + onSuggestionClick={(item) => { + // Suggestions always send a prompt (item.prompt, falling back to its + // title) — unlike cards, the host doesn't branch on behavior. + const text = item.prompt ?? item.title + setSubmissions((prev) => [ + ...prev, + { text, files: [], context: null, quote: null }, + ]) + }} + welcomeScreenCards={cardsWithBehavior} isWelcomeScreen={isWelcomeScreen} fullscreen={fullscreen} /> @@ -359,6 +441,15 @@ export const WithWelcomeCards: Story = { }, } +export const WithWelcomeSuggestions: Story = { + args: { + isWelcomeScreen: true, + fullscreen: true, + welcomeScreenSuggestions: WELCOME_SUGGESTIONS, + disclaimer: DISCLAIMER, + }, +} + export const InProgress: Story = { args: { inProgress: true,