Skip to content

AI-First Starting UX: Build with AI path#4902

Open
lmac-1 wants to merge 19 commits into
4848-ai-first-starting-ux-parentfrom
4868-build-with-ai
Open

AI-First Starting UX: Build with AI path#4902
lmac-1 wants to merge 19 commits into
4848-ai-first-starting-ux-parentfrom
4868-build-with-ai

Conversation

@lmac-1

@lmac-1 lmac-1 commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

What I need in this review: Focus on the "Build with AI" path of the epic. Any big code organisation problems will be fixed in the clean up issue.

Description

This PR adds the "Build with AI path" of the #4848 AI-First Starting UX epic.

How it works:

  1. User goes to /new and types a prompt in the "Build with AI" textarea
  2. Pressing Enter or clicking "Build it →" dismisses the landing screen and opens the AI panel with the prompt pre
    sent
  3. The canvas stays blank while the AI processes
  4. When the AI returns YAML, it is auto-applied to the canvas and saved to the DB automatically — no "Create" button
  5. The URL transitions from /new to /projects/{id}/w/{id} without a page reload

Key decisions:

  • Auto-save is silent. No "Workflow saved" toast fires on first creation — it would be noise in a flow the user didn't manually trigger.
  • Panel lock while unsaved. The AI panel cannot be closed (⌘K disabled, close button hidden) while the workflow hasn't been saved yet. Closing it would leave the user on a blank canvas with no path forward.
  • Validation errors route to chat, not toasts. If the AI returns invalid YAML, the error is injected as a message into the chat panel so the user can ask the AI to fix it, rather than hitting a dead-end toast.
  • Save failure shows a Retry button. If the DB save fails, a toast fires with a Retry action that retries only the save — the YAML has already been applied to the canvas so re-importing it is unnecessary. Fit-view is skipped on failure so the canvas doesn't zoom into an unpersisted workflow.
  • Streaming dedup via appliedViaStreamingRef. The streaming streamingChanges event and the final new_message both carry the same YAML. The ref is set before the streaming apply and cleared by the auto-apply effect after skipping, preventing a double import. The ref is also reset on apply or save failure so the new_message path can retry if streaming failed.
  • URL params are blocked on /new. Params like ?panel=run, ?panel=editor, ?panel=settings, and ?chat=true could open panels or bypass the landing screen before the user has done anything. The store ignores all URL params at init time when isNewWorkflow=true, and WorkflowEditor guards its URL→panel sync effect with the same flag. This prevents e.g. someone sharing a /new?panel=run link that skips straight to the run panel on a blank workflow.

Closes #4868

Validation steps

  1. Go to /new, type a prompt, press Enter — confirm landing screen dismisses, AI panel opens with prompt pre-sent
  2. Wait for AI to return YAML — confirm workflow appears on canvas and URL updates to /w/{id} without a reload
  3. While AI is processing, ⌘K — should do nothing
  4. After creation, confirm panel can be freely opened and closed
  5. Test with a prompt that produces invalid YAML (might be hard to do - might have to hack it with Claude) — confirm error appears in the chat panel, canvas stays blank, panel stays locked

Additional notes for the reviewer

  1. What do you think about the toolbar appearing? Is the effect very jumpy and jarring? What could be the alternative? A completely read-only toolbar before save?
  2. We are still working out how the disclaimer will work in this new flow. To be addressed in follow-up work.
  3. There are still details to be ironed out. For example, you can still "View conversations" in AI tab before you have created a workflow. Still need to iron out other areas that you shouldn't be able to "get to" before a workflow is created. This can be done in follow-up work.
  4. I am not satisfied with the error handling flow, and might need help to tidy this up in some follow-on work for the AI assistant in general - from a Claude review: the long tail of fix(useAIWorkflowApplications): ... commits (9+) is real evidence of implicit state churn, not just normal bug fixing. appliedViaStreamingRef alone is now reset from four different call sites for four different failure paths, alongside appliedMessageIdsRef and hasLoadedSessionRef tracking overlapping concerns. This is the classic shape of state that wants to be an explicit state machine (idle | streaming-applied | applying | save-failed) instead of booleans mutated ad hoc — worth a follow-up ticket before more logic piles on top.
  5. When reviewing, make it clear that there is still some legacy code from the old "Create a new workflow" route using the side panel. This will be cleaned up in a follow up issue.
  6. Landing screen gate is reactive, not static. createUIStore is created once inside useState — on a slow connection the channel hasn't joined yet, so isNewWorkflow is false at init time. Initialising showLandingScreen: true always and moving the isNewWorkflow guard into useShowLandingScreen (which reads from the reactive SessionContextStore) means the landing screen correctly appears once the channel joins, even on slow connections.

AI Usage

Please disclose whether you've used AI anywhere in this PR (it's cool, we just
want to know!):

  • I have used Claude Code
  • I have used another model
  • I have not used AI

You can read more details in our
Responsible AI Policy

Pre-submission checklist

  • I have performed an AI review of my code (we recommend using /review
    with Claude Code)
  • I have implemented and tested all related authorization policies.
    (e.g., :owner, :admin, :editor, :viewer)
  • I have updated the changelog.
  • I have ticked a box in "AI usage" in this PR

lmac-1 added 5 commits June 26, 2026 13:51
…ew workflows

When YAML validation fails (syntax, ID format, schema) during handleApplyWorkflow
and the workflow is new, inject the error as an assistant message into the AI chat
thread instead of showing a toast. Save failures keep the existing toast behaviour.
Add tests for the six untested behaviours introduced in #4868:
- auto-save called after importWorkflow when isNewWorkflow
- validation errors routed to onValidationError callback, not toast
- save failures show toast and do not invoke onValidationError
- close button absent when onClose is undefined
- empty-canvas placeholder suppressed when AI panel is open

Also adds saveWorkflow to mockWorkflowActions and isNewWorkflow: false
to all existing handleApplyWorkflow renderHook calls to match the
updated hook interface.
…e fails

When saveWorkflow rejects on a new workflow, fit-view was incorrectly
dispatched after doneApplyingWorkflow, implying the workflow was
successfully persisted when it wasn't. Add a saveSucceeded flag and
skip fit-view when save fails.

Also wires up the Retry action on the save-failure toast so the user
can re-attempt the full apply+save without losing their canvas state.
@github-project-automation github-project-automation Bot moved this to New Issues in Core Jun 26, 2026
lmac-1 added 13 commits June 29, 2026 15:18
…ies workflow

The workflow was being applied twice — once via streaming and again when the
final new_message arrived — causing a transient dirty state (unsaved red dot)
between the second import and save.

Consolidate the deduplication flag into useAIWorkflowApplications so the
"mark as applied + skip" logic happens atomically in one place rather than
split across two separate useEffect passes in the wrapper.

Test changes: delete a duplicate test identical to an existing case, tighten
the streaming-skip assertion to also verify saveWorkflow is not called.
On /new, URL params like ?chat=true, ?method=template, ?panel=run,
?panel=editor, and ?panel=settings could bypass the landing screen or
open panels that shouldn't be reachable before the user takes an action.

- createUIStore: return clean defaults when isNewWorkflow=true so the
  store ignores all URL params at init time
- WorkflowEditor: guard the URL->panel sync effect with isNewWorkflow
  so ?panel=run can't open the run panel
- WorkflowEditor: short-circuit isIDEOpen and showInspector on new
  workflows so FullScreenIDE (z-50) and Inspector can't render above
  the landing screen via URL params
- Add TODO-AI-FIRST annotations on the method URL sync and old
  placeholder block for cleanup when the left-panel flow is removed
…g YAML

When the initial save fails after AI applies a workflow, the Retry button
was re-running the full handleApplyWorkflow (importWorkflow + save). Add a
saveWorkflowRef that points at the latest saveWorkflow callback so Retry
only calls save, skipping the already-successful canvas apply.

Also set duration: Infinity on the save-failure toast so it persists until
the user explicitly retries or navigates away.
…ntent

The error rendering for assistant messages changed: non-empty error content
now renders inline in a styled red box (ai-validation-error) rather than
showing the hardcoded 'Failed to send message' banner. Split the single
test into two cases — one for non-empty content (styled box) and one for
empty content (banner).
…ations test fixtures

Both test files were missing the now-required saveWorkflow field in
mockWorkflowActions and the required isNewWorkflow boolean at every
renderHook call site, causing TypeScript compile errors.
… failure

When streaming fires mid-conversation and handleApplyWorkflow fails
(e.g. invalid YAML, importWorkflow throws), appliedViaStreamingRef was
left as true. The session-load guard only runs once per session, so
the next real new_message would hit the early-return and the workflow
would silently never be applied.

Reset the ref in the catch block so a failed streaming apply doesn't
block the subsequent settled-message auto-apply.
…toast

The save-failure Retry onClick used void to discard the promise, so a
second save failure was silently swallowed with no user feedback.

Chain a .catch on the retry promise to show another alert if the
retry also fails.
… channel join

createUIStore initialises once inside useState — if the channel hasn't
joined by mount time, isNewWorkflow is false and showLandingScreen was
permanently frozen as false.

Initialize showLandingScreen to true and move the isNewWorkflow gate into
useShowLandingScreen, where it reads from the reactive SessionContextStore.
On slow connections the landing screen now correctly appears once the
channel joins and delivers isNewWorkflow=true.
…on disconnect

saveWorkflow returns null (not throws) when the WebSocket is disconnected.
The null return was silently treated as success — saveSucceeded stayed true,
fit-view fired, and the landing screen stayed dismissed.

Check the return value and throw on null so the existing catch path handles
it: shows the save-failure toast with a Retry button and sets saveSucceeded
to false. The retry path is also fixed to handle null via an async IIFE.
…failure after streaming apply

When importWorkflow succeeded but saveWorkflow subsequently failed, the
inner catch set saveSucceeded=false but never reset appliedViaStreamingRef.
The outer catch (which does reset it) was never reached, leaving the ref
true. The next confirmed message from the server was then silently skipped
by the auto-apply effect.

Reset the ref in the inner save-failure catch, mirroring the existing reset
in the outer catch. Covers both thrown errors and null returns (disconnect).
…wRef

The ref was assigned but never called — the Retry toast uses saveWorkflowRef
and callers receive handleApplyWorkflow directly from the hook return value.
@lmac-1 lmac-1 marked this pull request as ready for review June 30, 2026 11:13
@github-actions

Copy link
Copy Markdown

All 15 changed files are frontend-only (TypeScript/React in assets/js/collaborative-editor/ and matching tests). No Elixir, Ecto queries, web-layer endpoints, policies, or Repo writes are touched, so none of S0/S1/S2 are in scope.

Security Review ✅

  • S0 (project scoping): N/A — all changes are frontend React/TypeScript in assets/js/collaborative-editor/ with no new queries or web-layer entrypoints.
  • S1 (authorization): N/A — no new controllers, LiveView events, or policy modules; existing useWorkflowReadOnly/isNewWorkflow gating is unchanged.
  • S2 (audit trail): N/A — no Repo.insert/update/delete on config resources; saveWorkflow continues to flow through the existing server-side persistence path.

@lmac-1 lmac-1 requested a review from elias-ba June 30, 2026 11:40
@nintyboy

nintyboy commented Jul 1, 2026

Copy link
Copy Markdown

Hey @lmac-1 popped together some looms for design review:

No Ai : https://www.loom.com/share/6d76a5aea6e54a0bab8a577b6d432d26
With AI: https://www.loom.com/share/3c53229a24324705aa288b6135bd9e8f

@elias-ba elias-ba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice work Lucy 🙏 The Build with AI flow feels good to use and the tests are genuinely thorough. I left a fair few inline comments but please don't be scared by the count - most are questions or non-blocking. Three I'd love us to sort before this merges:

  • Double toast on save failure - the wrapped saveWorkflow already shows its own error toast and re-throws, so we get two for one failure.
  • crypto.randomUUID - I think this can throw on a self-hosted HTTP deploy. We already have a randomUUID() helper in common.js for exactly this.
  • Getting stuck in the panel - if the auto-save keeps failing, there's no close button and no way back to the canvas. Not sure of the best fix, keen to hear your thoughts.

Everything else is either a question for you / product (auto-saving refinements, the panel escape) or fits nicely into the cleanup issue you already planned, so no rush there.

And your point 4 about the ref churn is spot on - I came at it from a few different angles and kept landing on the same thing. I think a small state machine would kill a whole class of these quietly-skipped bugs. Really good instinct flagging it.

Not blocking the epic branch in my view once those three are handled. Thanks again, I really enjoyed reviewing this one 🙌

Comment on lines +228 to +268
} catch (saveError) {
saveSucceeded = false;
if (appliedViaStreamingRef) appliedViaStreamingRef.current = false;
console.error('[AI Assistant] Failed to save workflow:', saveError);
notifications.alert({
title: 'Failed to save workflow',
description:
saveError instanceof Error
? saveError.message
: 'Unknown error occurred',
duration: Infinity, // toast only dismisses by clicking 'x' so the user definitely sees the error
action: {
label: 'Retry',
onClick: () => {
void (async () => {
try {
const saved = await saveWorkflowRef.current?.({
silent: true,
});
if (!saved)
throw new Error(
'Your connection was lost. Reconnect and try again.'
);
} catch (retryError: unknown) {
console.error(
'[AI Assistant] Retry save failed:',
retryError
);
notifications.alert({
title: 'Failed to save workflow',
description:
retryError instanceof Error
? retryError.message
: 'Unknown error occurred',
});
}
})();
},
},
});
}

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think we get two toasts here for a single failure. The saveWorkflow we call is the wrapped one from useWorkflowActions, and that wrapper already pops its own "Failed to save" alert (with a Retry) and then re-throws - so this catch lands a second one on top. The { silent: true } only mutes the success toast sadly, not the error path. The disconnect case is actually fine, the wrapper returns null and we throw the connection-lost message ourselves. So maybe we keep just the !saved branch here and let the wrapper own the real errors ? Side note while I was in here: the new test stubs saveWorkflow as a bare reject, which skips the wrapper, so it never catches this.

const onValidationError = useCallback(
(errorMessage: string) => {
const message: Message = {
id: crypto.randomUUID(),

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one caught my eye. I'm fairly sure crypto.randomUUID will throw on a self-hosted HTTP box - it's only defined in a secure context (HTTPS or localhost), so on a plain LAN deploy it's undefined, and here it throws before the message ever gets added. So when the AI returns invalid YAML the user just sees... nothing. We already dodge this everywhere else with the randomUUID() helper in common.js (Math.random based, see WorkflowDiagram.tsx / useConnect.ts). Could we reuse that here ?

<AIAssistantPanel
isOpen={isAIAssistantPanelOpen}
onClose={handleClosePanel}
onClose={isNewWorkflow ? undefined : handleClosePanel}

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something I'd like to think through with you: I think a user can get trapped here if the auto-save fails. isNewWorkflow only flips to false on a successful save (clearIsNewWorkflow, useWorkflow.tsx:436), so while it keeps failing the panel has no close button (onClose is undefined), Cmd+K is off, and the canvas is gated behind the new-workflow guards. Their only escape is the Retry toast or a full reload. Is that what we want ? Feels like they should at least be able to close the panel or drop back to the landing screen when the save hasn't gone through. I honestly don't have a clean fix in mind - curious what you think.

Comment on lines +220 to +227
if (isNewWorkflow) {
try {
const saved = await saveWorkflow({ silent: true });
if (!saved) {
throw new Error(
'Your connection was lost. Reconnect and try again.'
);
}

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of a question than a problem. Once the first save happens, clearIsNewWorkflow() runs and isNewWorkflow is false from then on. So the very first AI message auto-saves, but every refinement after it ("add a second job") imports onto the canvas and just leaves the unsaved dot - nothing persists until they hit Save. It matches the normal editor so it's defensible, but inside this AI-first flow it feels a little inconsistent, and I'm not sure an AI-first user would realise they need to save. Intended for now, or should refinements save too ? Probably a product call.

Comment on lines +512 to +518
// Streaming already applied this YAML — skip the re-import to avoid
// a transient dirty state (Y.Doc write → unsaved red dot) between
// the import and save that would otherwise follow.
if (appliedViaStreamingRef?.current) {
appliedViaStreamingRef.current = false;
return;
}

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, and honestly more of a nagging worry than a bug I could reproduce. This skip depends on the ref being cleared on every path. If streaming applies the YAML (ref = true) but the matching new_message never comes back as success + code, the ref stays true and the next unrelated response gets skipped right here, silently - no import, no error. It's basically the churn you called out in point 4, four reset sites is a lot to keep straight. A little state machine (or having handleApplyWorkflow return success and setting the ref from that) would make the worry go away. Totally happy to leave it for the follow-up though.

Comment on lines +273 to +287
// If streaming set this ref before the apply failed, clear it so the
// next real new_message isn't silently skipped by the auto-apply guard.
if (appliedViaStreamingRef) appliedViaStreamingRef.current = false;

const errorMessage =
error instanceof Error ? error.message : 'Invalid workflow YAML';

if (isNewWorkflow && onValidationError) {
onValidationError(errorMessage);
} else {
notifications.alert({
title: 'Failed to apply workflow',
description: errorMessage,
});
}

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think invalid YAML ends up posting two identical error bubbles. Streaming tries it first, fails, and this catch resets the ref and fires onValidationError (bubble 1). Then the final new_message shows up with the same YAML, but the ref is false again so the skip guard at line 515 lets it through - it re-applies, fails, bubble 2. In the happy path that guard is exactly what stops the double apply, we just lose it on the failure path. Did you bump into this when you were testing the invalid-YAML case ?

Comment on lines +310 to +317
isNewWorkflow,
onValidationError,
saveWorkflow,
]
);

// Keep ref pointing at the latest callback so Retry toast closures never go stale
saveWorkflowRef.current = saveWorkflow;

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny perf nit, non-blocking. saveWorkflow from useWorkflowActions is a fresh function every render (it's an IIFE at useWorkflow.tsx:393 and the hook isn't memoized), so pulling it into these deps makes handleApplyWorkflow new each render, which makes the auto-apply effect tear down and re-run every render - even on every streaming token. No correctness issue since appliedMessageIdsRef dedupes, it's just churn. Since you already keep saveWorkflowRef around, maybe the callback reads saveWorkflowRef.current(...) and we drop saveWorkflow from the deps ?

Comment on lines +548 to +560
{message.status === 'error' &&
!isStreaming(message) &&
message.content.trim() ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2"
data-testid="ai-validation-error"
>
<div className="flex items-start gap-2">
<span className="hero-exclamation-circle h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700 leading-relaxed">
{message.content}
</p>
</div>

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this quietly drops the Retry button for genuine send failures that happen to have text. Before, any assistant message with status: 'error' got the "Failed to send / Retry" banner. Now anything with content lands in this read-only red box, and the banner underneath is gated on !content.trim(). So a generation that dies after streaming a bit of text (it gets marked error over in AIChannelRegistry.ts:682) shows up here with no way to retry. I wonder if an explicit field like errorKind: 'validation' | 'transport' would be safer than leaning on content.trim() to tell them apart ? Not a blocker for this PR though.

@@ -1 +1 @@
/**

@elias-ba elias-ba Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last one, promise 😄 I only noticed because I was scrolling for a while - this file has crept up to 700 lines. That's quite big. Nothing for now, but maybe in the cleanup pass we peel the save / streaming cases out into their own file ?

@github-project-automation github-project-automation Bot moved this from New Issues to In review in Core Jul 3, 2026
… store state

appliedViaStreamingRef needed manual resets at four separate failure
sites and was invisible outside the component. Replace it with a
streamingApply record ({ yaml, saveFailed }) on AIAssistantStore,
written only after a successful streaming import, so failed applies
never leave a stale flag behind.

The auto-apply effect now compares the final message's YAML against
the record instead of checking a boolean: matching content and a
settled save means skip; matching content with a save still owed
means retry the save without re-importing; anything else applies
normally.

Also give the "failed to save" toast a stable id so repeated
failures update it in place instead of stacking duplicates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

3 participants