diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index aa79aef7..865b257e 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -223,14 +223,9 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); - // Report the app launch with the browser's canonical tz/locale so the backend - // can emit analytics app_lifecycle.opened with values that work in packaged, - // dev, and open-source builds. Guarded once per page load; backend dedupes per process. + // Report the app launch with the browser's canonical tz/locale so the backend can emit analytics app_lifecycle.opened with values that work in packaged, dev, and open-source builds. Guarded once per page load; backend dedupes per process. reportAppOpened(); - // Connected subscriptions live in their own slice; without this the dashboard - // (and the onboarding gate) think no model is connected until the user opens - // Settings > Models, so a fresh launch shows a false "connect a model" empty - // state and the welcome cursor never fires. Refetched after sync + on focus below. + // Connected subscriptions live in their own slice; without this the dashboard (and the onboarding gate) think no model is connected until the user opens Settings > Models, so a fresh launch shows a false "connect a model" empty state and the welcome cursor never fires. Refetched after sync + on focus below. dispatch(fetchSubscriptionStatus()); fetch(`${API_BASE}/subscription/sync`, { method: 'POST' }) .then((r) => { @@ -238,14 +233,10 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = }) .catch(() => {}) .finally(() => { - // Arm the zero-config free trial when nothing is connected so a brand-new - // user can run an agent immediately. The backend no-ops if a real key or - // subscription exists, so this is safe to fire on every launch. + // Arm the zero-config free trial when nothing is connected so a brand-new user can run an agent immediately. The backend no-ops if a real key or subscription exists, so this is safe to fire on every launch. fetch(`${API_BASE}/subscription/free-trial/mint`, { method: 'POST' }) .catch(() => {}) - // The backend arms server-side regardless of whether the browser can read the mint - // response (a transient boot-time CORS/timing miss makes `data` unreadable), so refetch - // unconditionally, the GET is the only reliable signal the UI gets that it armed. + // The backend arms server-side regardless of whether the browser can read the mint response (a transient boot-time CORS/timing miss makes `data` unreadable), so refetch unconditionally, the GET is the only reliable signal the UI gets that it armed. .finally(() => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); dispatch(markFreeTrialArmSettled()); }); }); }, [dispatch]); @@ -256,21 +247,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = return () => window.removeEventListener('focus', onFocus); }, [dispatch]); - // 9Router starts in the BACKGROUND now, so the boot fetches above can land while - // it's still coming up: /models omits subscription models and /subscriptions/status - // reports nothing connected, leaving the picker empty and a real sub looking - // disconnected. The only other re-sync is window 'focus', which never fires on a - // window that's already focused at launch, so it used to stay broken until a manual - // Cmd+Shift+R. Re-pull models + status until 9Router answers (running), capped so a - // machine where it never comes up doesn't poll forever. + // 9Router starts in the BACKGROUND now, so the boot fetches above can land while it's still coming up: /models omits subscription models and /subscriptions/status reports nothing connected, leaving the picker empty and a real sub looking disconnected. The only other re-sync is window 'focus', which never fires on a window that's already focused at launch, so it used to stay broken until a manual Cmd+Shift+R. Re-pull models + status until 9Router answers (running), capped so a machine where it never comes up doesn't poll forever. const nineRouterUp = useAppSelector((s) => s.subscriptions.status?.running === true); useEffect(() => { if (nineRouterUp) { - // 9Router answered, but its provider list (/api/providers) can lag is_running by - // a beat on a cold start, so the fetch that flipped us 'up' may still be missing - // subscription rows. Two bounded follow-up pulls catch them, then we stop. When - // there's genuinely no sub this is just a couple of cheap localhost GETs, never a - // wait on something that doesn't exist. + // 9Router answered, but its provider list (/api/providers) can lag is_running by a beat on a cold start, so the fetch that flipped us 'up' may still be missing subscription rows. Two bounded follow-up pulls catch them, then we stop. When there's genuinely no sub this is just a couple of cheap localhost GETs, never a wait on something that doesn't exist. const t1 = window.setTimeout(() => { dispatch(fetchSubscriptionStatus()); dispatch(fetchModels()); }, 1500); const t2 = window.setTimeout(() => { dispatch(fetchSubscriptionStatus()); dispatch(fetchModels()); }, 3500); return () => { window.clearTimeout(t1); window.clearTimeout(t2); }; @@ -339,10 +320,7 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children } const settingsLoaded = useAppSelector((s) => s.settings.loaded); const byProvider = useAppSelector((s) => s.models.byProvider); const modelsLoaded = useAppSelector((s) => s.models.loaded); - // Until 9Router answers, /models omits subscription models, so the saved default - // can look "no longer available" when it's really just not loaded yet. Reconciling - // then would clobber a real sub user's default down to a fallback (and persist it). - // Only reconcile against the complete list. + // Until 9Router answers, /models omits subscription models, so the saved default can look "no longer available" when it's really just not loaded yet. Reconciling then would clobber a real sub user's default down to a fallback (and persist it). Only reconcile against the complete list. const nineRouterUp = useAppSelector((s) => s.subscriptions.status?.running === true); const [warning, setWarning] = useState<{ from: string; to: string; provider: string } | null>(null); diff --git a/frontend/src/app/components/InlineEditableTitle.tsx b/frontend/src/app/components/InlineEditableTitle.tsx index b11d4188..543be5ce 100644 --- a/frontend/src/app/components/InlineEditableTitle.tsx +++ b/frontend/src/app/components/InlineEditableTitle.tsx @@ -10,19 +10,14 @@ interface Props { value: string; // Called with the trimmed new title only when it actually changed. onCommit: (next: string) => void; - // Layout + text styling shared by the read-only text and the input so the - // two states line up (pass flex/font/color here). + // Layout + text styling shared by the read-only text and the input so the two states line up (pass flex/font/color here). sx?: SxProps; placeholder?: string; - // Optional custom display node (e.g. the chat card's Typewriter); falls - // back to a plain Typography of `value` when omitted. + // Optional custom display node (e.g. the chat card's Typewriter); falls back to a plain Typography of `value` when omitted. children?: React.ReactNode; } -// Click-to-rename title. Reads as plain text until clicked, then becomes an -// inline input that commits on Enter/blur and cancels on Escape. Lives on -// pointer-drag card headers, so it stops pointer propagation (+ data-no-drag) -// to avoid starting a card drag while editing. +// Click-to-rename title. Reads as plain text until clicked, then becomes an inline input that commits on Enter/blur and cancels on Escape. Lives on pointer-drag card headers, so it stops pointer propagation (+ data-no-drag) to avoid starting a card drag while editing. export default function InlineEditableTitle({ value, onCommit, sx, placeholder, children }: Props) { const c = useClaudeTokens(); const [editing, setEditing] = useState(false); @@ -97,9 +92,7 @@ export default function InlineEditableTitle({ value, onCommit, sx, placeholder, return ( e.stopPropagation()} title="Click to rename" sx={{ diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 07c8e5d6..8e02cf0b 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -13,8 +13,7 @@ import Button from '@mui/material/Button'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; import InputBase from '@mui/material/InputBase'; -// One outlined icon language for the sidebar: thin monoline glyphs (not the -// filled Material clip-art) so the rail reads as designed, not assembled. +// One outlined icon language for the sidebar: thin monoline glyphs (not the filled Material clip-art) so the rail reads as designed, not assembled. import { LayoutDashboard } from 'lucide-react'; import PsychologyIcon from '@mui/icons-material/PsychologyOutlined'; import BuildIcon from '@mui/icons-material/BuildOutlined'; @@ -78,17 +77,12 @@ const AppShell: React.FC = () => { }; return fn as typeof navigateRaw; }, [navigateRaw]); - // Navigate to an app instantly on click. The old debounce here swallowed clicks the - // user could see (felt broken) and never actually fixed the crash, since letting each - // app load defeats the debounce anyway. The real GPU-churn source (the WebGL loading - // placeholder) is now CSS, and the 250ms preview gate still skips webviews for apps - // switched-past too fast, so instant navigation is safe. + // Navigate to an app instantly on click. The old debounce here swallowed clicks the user could see (felt broken) and never actually fixed the crash, since letting each app load defeats the debounce anyway. The real GPU-churn source (the WebGL loading placeholder) is now CSS, and the 250ms preview gate still skips webviews for apps switched-past too fast, so instant navigation is safe. const navigateToApp = useCallback((id: string) => { navigate(`/apps/${id}`); }, [navigate]); const location = useLocation(); - // React Router (HashRouter) stores a monotonic index in history state. location - // re-renders on every nav, by which point window.history.state.idx is updated. + // React Router (HashRouter) stores a monotonic index in history state. location re-renders on every nav, by which point window.history.state.idx is updated. const historyIdx = (window.history.state?.idx as number | undefined) ?? 0; const maxHistoryIdx = useRef(0); maxHistoryIdx.current = Math.max(maxHistoryIdx.current, historyIdx); @@ -143,22 +137,17 @@ const AppShell: React.FC = () => { const modelsByProvider = useAppSelector((s) => s.models.byProvider); const modelsLoaded = useAppSelector((s) => s.models.loaded); const hasModelConnected = Object.keys(modelsByProvider).length > 0; - // During an active free trial the user CAN run things, so a red "no model connected" - // warning is misleading and discouraging (it sits right above the working starter chips). - // The trial flips connection_mode back to own_key the moment it's spent, so this banner - // returns then, landing the connect-a-model nudge after the win, not before it. + // During an active free trial the user CAN run things, so a red "no model connected" warning is misleading and discouraging (it sits right above the working starter chips). The trial flips connection_mode back to own_key the moment it's spent, so this banner returns then, landing the connect-a-model nudge after the win, not before it. const freeTrialActive = useAppSelector((s) => { const d = s.settings.data as any; return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token); }); - // Trial just ran dry (had an allotment, now 0, off the free lane): a quiet connect nudge, not the - // red error wall. Runs refill, so it's "for now". + // Trial just ran dry (had an allotment, now 0, off the free lane): a quiet connect nudge, not the red error wall. Runs refill, so it's "for now". const freeTrialSpent = useAppSelector((s) => { const d = s.settings.data as any; return !!(d && (d.free_trial_runs_limit ?? 0) > 0 && d.free_trial_remaining === 0 && d.connection_mode !== 'free-trial'); }); - // Post-wow: on the free lane and already got value (spent >= 1 run); offer the unlimited path they - // likely already own while they're happy, not when they're blocked. + // Post-wow: on the free lane and already got value (spent >= 1 run); offer the unlimited path they likely already own while they're happy, not when they're blocked. const freeTrialUsed = useAppSelector((s) => { const d = s.settings.data as any; if (!d || d.connection_mode !== 'free-trial' || !d.free_trial_token) return false; @@ -167,8 +156,7 @@ const AppShell: React.FC = () => { return limit > 0 && (limit - remaining) >= 1; }); const freeTrialResetsAt = useAppSelector((s) => (s.settings.data as any)?.free_trial_resets_at ?? null); - // Coarse "~3h" / "~20m" label for when the rolling window refills; null when unknown or basically now. - // Static (not a ticking countdown) on purpose: a per-second timer is needless churn for a 5h window. + // Coarse "~3h" / "~20m" label for when the rolling window refills; null when unknown or basically now. Static (not a ticking countdown) on purpose: a per-second timer is needless churn for a 5h window. const refillLabel = React.useMemo(() => { if (!freeTrialResetsAt) return null; const secs = freeTrialResetsAt - Date.now() / 1000; @@ -178,10 +166,7 @@ const AppShell: React.FC = () => { return `~${Math.max(1, Math.round(secs / 60))}m`; }, [freeTrialResetsAt]); - // Paid (openswarm-pro) usage meter: same calm "you're near/at the cap, here's when it's back" - // pattern as the free-trial nudge, but the bar IS the message. Only fires in pro mode on real - // server-owned usage (requests_in_window/plan_limit), and only once near the cap, so it never - // clutters the normal flow. window_ends_at is unix MS (the trial's resets_at is seconds). + // Paid (openswarm-pro) usage meter: same calm "you're near/at the cap, here's when it's back" pattern as the free-trial nudge, but the bar IS the message. Only fires in pro mode on real server-owned usage (requests_in_window/plan_limit), and only once near the cap, so it never clutters the normal flow. window_ends_at is unix MS (the trial's resets_at is seconds). const proUsage = useAppSelector((s) => { const d = s.settings.data as any; if (!d || d.connection_mode !== 'openswarm-pro') return null; @@ -199,16 +184,14 @@ const AppShell: React.FC = () => { const h = Math.floor(secs / 3600); return h >= 1 ? `~${h}h` : `~${Math.max(1, Math.round(secs / 60))}m`; }, [proUsage]); - // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it - // flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.) + // Hold the banner until the boot free-trial mint settles, else a brand-new user sees it flash red for the ~1-3s the trial takes to arm. (Offline shows immediately, it's its own signal.) const freeTrialArmSettled = useAppSelector((s) => s.settings.freeTrialArmSettled); // The red wall is for genuine "no way to run" only; the free-trial states get the quiet nudge below. const showWarningBanner = !isOnline || (modelsLoaded && freeTrialArmSettled && !hasModelConnected && !freeTrialActive && !freeTrialSpent); const [ftNudgeDismissed, setFtNudgeDismissed] = useState(() => { try { return localStorage.getItem('os_ft_nudge_dismissed') === '1'; } catch { return false; } }); - // Spent nudge hides the moment they connect a real model; the post-wow nudge only shows on the - // trial lane (so it already implies no own model) and is dismissible. + // Spent nudge hides the moment they connect a real model; the post-wow nudge only shows on the trial lane (so it already implies no own model) and is dismissible. const showFreeTrialNudge = isOnline && ((freeTrialSpent && !hasModelConnected) || (freeTrialUsed && !ftNudgeDismissed)); const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion; @@ -768,13 +751,11 @@ const AppShell: React.FC = () => { overflow: 'auto', pt: 0.5, '&::-webkit-scrollbar': { width: 0 }, - // Tactile hover: the leading section icon springs once on row-hover, then settles. - // Interaction-only, never ambient. Scoped to ListItemIcon so the +/chevron stay put. + // Tactile hover: the leading section icon springs once on row-hover, then settles. Interaction-only, never ambient. Scoped to ListItemIcon so the +/chevron stay put. '& .MuiListItemIcon-root svg': { transition: 'transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)', }, - // Per-glyph hover choreography: each section icon reacts in its own way, - // springy then settles. Interaction-only, never ambient. + // Per-glyph hover choreography: each section icon reacts in its own way, springy then settles. Interaction-only, never ambient. '& [data-onboarding="sidebar-dashboards"]:hover .MuiListItemIcon-root svg': { transform: 'scale(1.14)', }, @@ -1249,8 +1230,7 @@ const AppShell: React.FC = () => { overflow: 'hidden', bgcolor: c.bg.page, position: 'relative', - // Float the content as a rounded inset panel ("column pill"): the chrome - // (bg.secondary) frames it, so there are no divider lines, just air + radius. + // Float the content as a rounded inset panel ("column pill"): the chrome (bg.secondary) frames it, so there are no divider lines, just air + radius. mt: '6px', mr: '6px', mb: '6px', diff --git a/frontend/src/app/components/Layout/animatedIcons.tsx b/frontend/src/app/components/Layout/animatedIcons.tsx index 94a56a44..711ef1d1 100644 --- a/frontend/src/app/components/Layout/animatedIcons.tsx +++ b/frontend/src/app/components/Layout/animatedIcons.tsx @@ -1,9 +1,6 @@ import { motion } from 'framer-motion'; -// True path-drawing sidebar glyphs: the SVG strokes themselves animate on hover -// (not just a transform), then settle. Always fully visible at rest, so a hover -// that's interrupted mid-flight never leaves a half-drawn icon. Geometry matches -// the lucide line-icons they replace so the static look is unchanged. +// True path-drawing sidebar glyphs: the SVG strokes themselves animate on hover (not just a transform), then settle. Always fully visible at rest, so a hover that's interrupted mid-flight never leaves a half-drawn icon. Geometry matches the lucide line-icons they replace so the static look is unchanged. const SPRING = { type: 'spring', stiffness: 380, damping: 20 } as const; diff --git a/frontend/src/app/components/Onboarding/OnboardingDirector.ts b/frontend/src/app/components/Onboarding/OnboardingDirector.ts index 2dbaa7e7..a6dc9e89 100644 --- a/frontend/src/app/components/Onboarding/OnboardingDirector.ts +++ b/frontend/src/app/components/Onboarding/OnboardingDirector.ts @@ -98,13 +98,7 @@ class OnboardingDirector { controller.abort(); } }; - // Yield to the user: the runtime fires this when, during a wait for a - // SPECIFIC click target, the user instead clicks somewhere off-script. Back - // off silently (reason 'user-cancel' suppresses acRuntime's recovery popup) - // rather than nagging or auto-performing the action. It is scoped to - // click-target waits in the runtime, so it can't cancel free-interaction - // waits (e.g. connecting a model in Settings, where the user must click - // non-tour controls). + // Yield to the user: the runtime fires this when, during a wait for a SPECIFIC click target, the user instead clicks somewhere off-script. Back off silently (reason 'user-cancel' suppresses acRuntime's recovery popup) rather than nagging or auto-performing the action. It is scoped to click-target waits in the runtime, so it can't cancel free-interaction waits (e.g. connecting a model in Settings, where the user must click non-tour controls). const onUserOffscript = () => { report('step_aborted_user_offscript', { step_id: stepId }); controller.abort('user-cancel'); diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index cf3f625a..4879d99b 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -65,8 +65,7 @@ const OnboardingPanel: React.FC = () => { const unlockedIds = useUnlockedStepIds(); const currentStep = useMemo(() => { - // Spotlight only lands on an unlocked, not-yet-done step, so we never tell - // the user to "Show me" something they haven't unlocked yet. + // Spotlight only lands on an unlocked, not-yet-done step, so we never tell the user to "Show me" something they haven't unlocked yet. const explicit = progress.currentStepId ? findStepById(progress.currentStepId) : null; @@ -87,10 +86,7 @@ const OnboardingPanel: React.FC = () => { // Stage name labels the panel; the count + bar stay global so progress never resets between stages. const stageOf = currentStep?.stage ?? 'get_started'; - // Count only what's UNLOCKED, not all 8. A brand-new user sees "0/2" (launch + - // connect), and the denominator grows as the first win unlocks the rest, so we - // never dump the whole feature surface on someone before their first output. - // Guard: never let completed exceed the shown total (data-weirdness safety). + // Count only what's UNLOCKED, not all 8. A brand-new user sees "0/2" (launch + connect), and the denominator grows as the first win unlocks the rest, so we never dump the whole feature surface on someone before their first output. Guard: never let completed exceed the shown total (data-weirdness safety). const done = progress.completedSteps.length; const total = Math.max(unlockedIds.size, done); @@ -135,9 +131,7 @@ const OnboardingPanel: React.FC = () => { if (!currentStep && !justDoneStep) return null; if (progress.panelMode === 'hidden') return null; - // Gate it like a game: on first run the tour stays out of the way entirely. The pill only - // appears once the user has earned it (their first agent finishes), at which point the - // reveal-after-win effect flips the panel to 'expanded' with the next single nudge. + // Gate it like a game: on first run the tour stays out of the way entirely. The pill only appears once the user has earned it (their first agent finishes), at which point the reveal-after-win effect flips the panel to 'expanded' with the next single nudge. if (progress.panelMode === 'pill' && !firstAgentDone && !progress.revealedAfterWin) return null; // Slide panel off-screen while AC runs so it doesn't sit on top of top-right targets (Skills install, "+ New app", etc). diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 3f207ece..3372749d 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -34,9 +34,7 @@ const OnboardingRoot: React.FC = () => { const settingsLoaded = useAppSelector((s) => s.settings.loaded); const firstAgentDone = useAppSelector(hasAnyAgentCompleted); - // The one gentle nudge: open the quiet pill ONCE, but only AFTER the first agent - // actually finishes, so it never pops mid-run. Respects a panel they've hidden or - // already expanded themselves, and never re-fires (revealedAfterWin sticks). + // The one gentle nudge: open the quiet pill ONCE, but only AFTER the first agent actually finishes, so it never pops mid-run. Respects a panel they've hidden or already expanded themselves, and never re-fires (revealedAfterWin sticks). useEffect(() => { if (!progress.initialized || !firstAgentDone) return; if (progress.revealedAfterWin || progress.panelMode !== 'pill') return; @@ -50,20 +48,14 @@ const OnboardingRoot: React.FC = () => { dispatch, ]); - // The welcome chat IS the user launching their first agent, so once it finishes, mark - // launch_agent done. Without this the post-win nudge repeats "Launch your first Agent" they - // just did: the skipIf baseline-capture re-arms on every completedSteps change and tends to - // snapshot the real send as "pre-existing", which freezes the step as un-completable. + // The welcome chat IS the user launching their first agent, so once it finishes, mark launch_agent done. Without this the post-win nudge repeats "Launch your first Agent" they just did: the skipIf baseline-capture re-arms on every completedSteps change and tends to snapshot the real send as "pre-existing", which freezes the step as un-completable. useEffect(() => { if (!progress.initialized || !firstAgentDone) return; if ((progress.completedSteps ?? []).includes('launch_agent')) return; dispatch(markStepCompleted('launch_agent')); }, [firstAgentDone, progress.initialized, progress.completedSteps, dispatch]); - // First run: the cursor pops into existence, pauses, then moves to and clicks the New Agent - // button (welcome_open step) which spawns the welcome chat. Fires once, only on the dashboard - // with a way to run and nothing launched yet. Fail-safe: if the cursor can't run, a manual - // New Agent click spawns the same welcome chat (handleNewAgent is welcome-aware). + // First run: the cursor pops into existence, pauses, then moves to and clicks the New Agent button (welcome_open step) which spawns the welcome chat. Fires once, only on the dashboard with a way to run and nothing launched yet. Fail-safe: if the cursor can't run, a manual New Agent click spawns the same welcome chat (handleNewAgent is welcome-aware). const welcomeOpenReady = useAppSelector( (s) => s.settings.loaded && diff --git a/frontend/src/app/components/Onboarding/_motionWin.tsx b/frontend/src/app/components/Onboarding/_motionWin.tsx index ddd90773..b61507c0 100644 --- a/frontend/src/app/components/Onboarding/_motionWin.tsx +++ b/frontend/src/app/components/Onboarding/_motionWin.tsx @@ -24,14 +24,7 @@ const stripFramerProps = (props: any) => { return out; }; -// Components that drive position via `animate={{ x, y }}` (ACPopup, ACMultiChoice, etc.) would otherwise lose their layout when the animate prop is stripped, because they have no fallback style.transform. We salvage the latest numeric x/y from animate and apply them as a transform so the div lands in the right place; no animation, just static placement. -// One cached component per tag. CRITICAL: without the cache the Proxy getter -// returns a NEW forwardRef component on every `motion.div` access, so React -// sees a different component type each render and REMOUNTS the DOM node every -// time. A freshly-mounted node has no previous transform to ease from, so CSS -// transitions never run (getAnimations() stays empty) and the cursor jumps -// instantly instead of gliding; the breathing pulse never animates either. -// Caching gives each tag a stable identity so React reconciles in place. +// Components that drive position via `animate={{ x, y }}` (ACPopup, ACMultiChoice, etc.) would otherwise lose their layout when the animate prop is stripped, because they have no fallback style.transform. We salvage the latest numeric x/y from animate and apply them as a transform so the div lands in the right place; no animation, just static placement. One cached component per tag. CRITICAL: without the cache the Proxy getter returns a NEW forwardRef component on every `motion.div` access, so React sees a different component type each render and REMOUNTS the DOM node every time. A freshly-mounted node has no previous transform to ease from, so CSS transitions never run (getAnimations() stays empty) and the cursor jumps instantly instead of gliding; the breathing pulse never animates either. Caching gives each tag a stable identity so React reconciles in place. const tagComponentCache: Record = {}; const motionShim: any = new Proxy({}, { get: (_target, tag: string) => { diff --git a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx index 5fd89df6..9b6e9075 100644 --- a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx +++ b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx @@ -56,14 +56,10 @@ const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 }; // On Windows the motionWin shim strips Framer Motion's animate prop, so controls.set({x,y}) never moves the wrapper. We bypass by reading the same store the popups read and applying style.transform directly; Mac is unaffected since Framer's own transform writes win the cascade. const IS_WIN = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows'); -// Windows fallback-ease duration. Mirrors the CSS `transform 420ms` transition -// on the cursor wrapper below; on Windows the Director holds for this (plus a -// small settle margin) after a moveTo/fadeOut so the CSS ease actually plays -// before the next step's instant write lands. +// Windows fallback-ease duration. Mirrors the CSS `transform 420ms` transition on the cursor wrapper below; on Windows the Director holds for this (plus a small settle margin) after a moveTo/fadeOut so the CSS ease actually plays before the next step's instant write lands. const WIN_EASE_MS = 420; -// A little spark when the cursor pops into existence: short orange lines shoot out from the -// tip and fade. Re-keyed on each pop so it replays. One-shot per mount (no infinite loop). +// A little spark when the cursor pops into existence: short orange lines shoot out from the tip and fade. Re-keyed on each pop so it replays. One-shot per mount (no infinite loop). const BURST_SPOKES = 6; const CursorBurst: React.FC<{ color: string }> = ({ color }) => ( <> @@ -134,14 +130,7 @@ const AgenticCursor = forwardRef((_props, ref) => { // Stop prior tracker so it doesn't snap the cursor back to its old anchor mid-animation. stopTrackingInternal(); if (IS_WIN) { - // Windows has no Framer runtime (controls.start is a no-op); the visual - // hop is the CSS transition on the wrapper, driven by cursorStore. - // TWO-STEP so Chromium actually animates: (1) commit the eased - // transition at the CURRENT position (cursorStore now flushes an - // instant-change), let it paint, then (2) move. Changing transform in - // the same recalc that flips transition none->420ms makes Chromium - // apply the move instantly (teleport). Then HOLD for the ease so the - // Director doesn't begin the next step mid-glide. + // Windows has no Framer runtime (controls.start is a no-op); the visual hop is the CSS transition on the wrapper, driven by cursorStore. TWO-STEP so Chromium actually animates: (1) commit the eased transition at the CURRENT position (cursorStore now flushes an instant-change), let it paint, then (2) move. Changing transform in the same recalc that flips transition none->420ms makes Chromium apply the move instantly (teleport). Then HOLD for the ease so the Director doesn't begin the next step mid-glide. writePos(posRef.current.x, posRef.current.y, true, false); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); writePos(x, y, true, false); @@ -159,9 +148,7 @@ const AgenticCursor = forwardRef((_props, ref) => { async fadeOut(to) { stopTrackingInternal(); if (IS_WIN) { - // Glide to the exit point via the same two-step arm as moveTo so the - // CSS ease actually runs, then hide. The opacity fade has no Framer - // runtime on Windows, so the cursor just disappears once it eases to `to`. + // Glide to the exit point via the same two-step arm as moveTo so the CSS ease actually runs, then hide. The opacity fade has no Framer runtime on Windows, so the cursor just disappears once it eases to `to`. writePos(posRef.current.x, posRef.current.y, true, false); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); writePos(to.x, to.y, true, false); diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index eea5e174..7183627f 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -60,9 +60,7 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise { }); } -// Resolve after `ms`, OR early the moment `stepId` lands in completedSteps. Lets a stale recovery -// popup auto-dismiss when the step it was apologizing for actually completes (e.g. launch_agent -// auto-completing right after a transient throw), instead of sitting the full read-timeout. +// Resolve after `ms`, OR early the moment `stepId` lands in completedSteps. Lets a stale recovery popup auto-dismiss when the step it was apologizing for actually completes (e.g. launch_agent auto-completing right after a transient throw), instead of sitting the full read-timeout. function sleepOrStepComplete(store: Store, stepId: string, ms: number): Promise { return new Promise((resolve) => { let settled = false; @@ -219,8 +217,7 @@ export async function runStep(args: RunStepArgs): Promise { "No worries, feel free to explore. Tap Show me whenever you're ready." + debugSuffix, ); - // 14s read window (ACPopup streams ~30ms/char), but bail the instant the step completes - // so a transient throw on a step that then auto-completes doesn't leave a dead-end "Show me". + // 14s read window (ACPopup streams ~30ms/char), but bail the instant the step completes so a transient throw on a step that then auto-completes doesn't leave a dead-end "Show me". await sleepOrStepComplete(store, step.id, 14000); ac.hidePopup(); } @@ -862,11 +859,7 @@ function waitForCondition( finish(false); return; } - // Off-script click during a wait for a specific target: if it's not any - // tour control and not the cursor/popup, the user has gone their own - // way, so tell the director to back off (it aborts the step silently). - // Scoped here to click-target waits so free-interaction waits - // (redux_predicate / event_bus) never cancel on a stray click. + // Off-script click during a wait for a specific target: if it's not any tour control and not the cursor/popup, the user has gone their own way, so tell the director to back off (it aborts the step silently). Scoped here to click-target waits so free-interaction waits (redux_predicate / event_bus) never cancel on a stray click. if (!(el instanceof Element)) return; if (el.closest('[data-onboarding], [data-select-type]')) return; for (let n: Element | null = el; n; n = n.parentElement) { diff --git a/frontend/src/app/components/Onboarding/ac/cursorStore.ts b/frontend/src/app/components/Onboarding/ac/cursorStore.ts index f8109b5d..958ce3dd 100644 --- a/frontend/src/app/components/Onboarding/ac/cursorStore.ts +++ b/frontend/src/app/components/Onboarding/ac/cursorStore.ts @@ -33,12 +33,7 @@ export const cursorStore = { // Visibility transitions bypass coalescing (mounts/unmounts must flush immediately). const visibilityChanged = merged.visible !== state.visible; - // `instant` flips the Windows CSS-transition mode (snap vs ease). Commit it - // immediately, like visibility, so moveTo can arm the eased transition a - // paint BEFORE it moves the cursor: a same-position arm write is otherwise - // coalesced silently here, so the move and the none->420ms transition flip - // land in one recalc and Chromium renders it as an instant jump. No-op on - // Mac (the wrapper there is Framer-driven and ignores `instant`). + // `instant` flips the Windows CSS-transition mode (snap vs ease). Commit it immediately, like visibility, so moveTo can arm the eased transition a paint BEFORE it moves the cursor: a same-position arm write is otherwise coalesced silently here, so the move and the none->420ms transition flip land in one recalc and Chromium renders it as an instant jump. No-op on Mac (the wrapper there is Framer-driven and ignores `instant`). const instantChanged = merged.instant !== state.instant; const dx = Math.abs(merged.x - state.x); const dy = Math.abs(merged.y - state.y); diff --git a/frontend/src/app/components/Onboarding/index.ts b/frontend/src/app/components/Onboarding/index.ts index 436331ce..b2e59e4f 100644 --- a/frontend/src/app/components/Onboarding/index.ts +++ b/frontend/src/app/components/Onboarding/index.ts @@ -1,5 +1,4 @@ -// Public surface for the Onboarding v2 system. AppShell mounts -// once; everything else is internal. +// Public surface for the Onboarding v2 system. AppShell mounts once; everything else is internal. export { default as OnboardingRoot } from './OnboardingRoot'; export { onboardingDirector } from './OnboardingDirector'; diff --git a/frontend/src/app/components/Onboarding/steps/index.ts b/frontend/src/app/components/Onboarding/steps/index.ts index 4dad27b7..5b218938 100644 --- a/frontend/src/app/components/Onboarding/steps/index.ts +++ b/frontend/src/app/components/Onboarding/steps/index.ts @@ -9,9 +9,7 @@ import { step07 } from './step07_installSkill'; import { step08 } from './step08_makeApp'; import { welcomeOpenStep } from './step00_welcomeNudge'; -// Value-first order: launch an agent (step03) FIRST so a brand-new user sees -// the product work on the free trial, then connect-your-own-model (step01). -// Everything else is "learn the features", revealed after the first win. +// Value-first order: launch an agent (step03) FIRST so a brand-new user sees the product work on the free trial, then connect-your-own-model (step01). Everything else is "learn the features", revealed after the first win. export const STEPS: OnboardingStep[] = [ step03, step01, @@ -23,8 +21,7 @@ export const STEPS: OnboardingStep[] = [ step08, ]; -// Resolvable by the Director but kept OUT of STEPS, so they never appear in the roadmap, -// the panel count, or the unlock chain. The first-run welcome nudge lives here. +// Resolvable by the Director but kept OUT of STEPS, so they never appear in the roadmap, the panel count, or the unlock chain. The first-run welcome nudge lives here. const HIDDEN_STEPS: OnboardingStep[] = [welcomeOpenStep]; export function findStepById(id: string): OnboardingStep | undefined { diff --git a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts index 7459ad38..73082e7d 100644 --- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts +++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts @@ -59,9 +59,7 @@ export function isYoutubeEnabled(s: RootState): boolean { export function hasAnyAgentLaunched(s: RootState): boolean { const sessions = s.agents?.sessions ?? {}; - // A draft is an unsent chat, not a launched agent. Counting drafts let the welcome draft - // pre-satisfy launch_agent at baseline-capture time, which froze the step as "pre-existing" - // so it never auto-completed, leaving "Launch your first Agent" stuck to-do after the chat. + // A draft is an unsent chat, not a launched agent. Counting drafts let the welcome draft pre-satisfy launch_agent at baseline-capture time, which froze the step as "pre-existing" so it never auto-completed, leaving "Launch your first Agent" stuck to-do after the chat. return Object.values(sessions).some((x: any) => x?.status && x.status !== 'draft'); } diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts index 282e97f1..3bfaa91f 100644 --- a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -1,9 +1,7 @@ import type { OnboardingStep } from './types'; import { S } from '../selectors'; -// First-run, invisible to the roadmap: the cursor pops into existence (handled by fadeIn, with -// the orange spark), pauses a beat, then moves to and clicks the New Agent button, which spawns -// the welcome chat. Static, no LLM. The delays give the pop and the move room to breathe. +// First-run, invisible to the roadmap: the cursor pops into existence (handled by fadeIn, with the orange spark), pauses a beat, then moves to and clicks the New Agent button, which spawns the welcome chat. Static, no LLM. The delays give the pop and the move room to breathe. export const welcomeOpenStep: OnboardingStep = { id: 'welcome_open', stage: 'get_started', diff --git a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts index f5cdcebc..789f5f01 100644 --- a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts +++ b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts @@ -5,9 +5,7 @@ import { hasModelConnected, hasFreeTrialActive, freeRunsLow } from './skipPredic export const step01: OnboardingStep = { id: 'connect_model', stage: 'get_started', - // Moved to last in "Get started": the user only meets this after they've - // seen value, framed as "keep going". Stays suppressed while the free trial - // is armed and runs aren't low; un-suppresses when they're about to run out. + // Moved to last in "Get started": the user only meets this after they've seen value, framed as "keep going". Stays suppressed while the free trial is armed and runs aren't low; un-suppresses when they're about to run out. index: 2, title: 'Keep going: connect your model', description: 'Your free runs are limited. Add your own model to keep building.', diff --git a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts index 020427cc..dc220549 100644 --- a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts +++ b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts @@ -13,8 +13,7 @@ export const step02: OnboardingStep = { videoDurationLabel: '0:24', // Narrowed to YouTube so users with other tools still get walked. skipIf: isYoutubeEnabled, - // Two beats only (open Actions, flip YouTube on); the chevron-peek and permission - // fine-tune popups were trimmed to give the step room to breathe. + // Two beats only (open Actions, flip YouTube on); the chevron-peek and permission fine-tune popups were trimmed to give the step room to breathe. ops: [ { kind: 'move_to', target: S.sidebarActions }, { kind: 'popup', text: 'Open Actions.' }, diff --git a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts index 02c259d3..244d18a6 100644 --- a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts +++ b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts @@ -5,9 +5,7 @@ import { hasAnyAgentLaunched, hasModelConnected, hasFreeTrialActive } from './sk export const step03: OnboardingStep = { id: 'launch_agent', stage: 'get_started', - // Value first: this leads WHEN there's a way to run (free trial armed, or a - // model connected). With nothing to run on, it skips so connect-model leads - // instead, which restores today's flow exactly (no trial = no regression). + // Value first: this leads WHEN there's a way to run (free trial armed, or a model connected). With nothing to run on, it skips so connect-model leads instead, which restores today's flow exactly (no trial = no regression). index: 1, title: 'Launch your first Agent', description: 'Tell the chat what you want done and a team gets to work.', @@ -15,9 +13,7 @@ export const step03: OnboardingStep = { videoDurationLabel: '0:24', skipIf: (s) => hasAnyAgentLaunched(s) || (!hasModelConnected(s) && !hasFreeTrialActive(s)), requiresDashboard: true, - // The cursor opens the chat FOR the user, then asks what they want. No canned - // prompt and no LLM here: it's a static move + simulated click + a hardcoded - // line; the user types their own thing and their team runs. + // The cursor opens the chat FOR the user, then asks what they want. No canned prompt and no LLM here: it's a static move + simulated click + a hardcoded line; the user types their own thing and their team runs. ops: [ { kind: 'move_to', target: S.newAgentButton }, { kind: 'popup', text: 'Let me open a chat for you.' }, diff --git a/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts b/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts index 47a4cd20..c8eda3e5 100644 --- a/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts +++ b/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts @@ -39,8 +39,7 @@ export const step05: OnboardingStep = { condition: { kind: 'event_bus', event: 'agent:attached_to_browser' }, timeoutMs: 90000, }, - // Guide, don't commandeer: invite the user to ask for any web task in their - // own words instead of typing + sending a canned prompt for them. + // Guide, don't commandeer: invite the user to ask for any web task in their own words instead of typing + sending a canned prompt for them. { kind: 'move_to', target: S.chatInput }, { kind: 'popup', diff --git a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts index d80f0d83..3273e664 100644 --- a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts +++ b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts @@ -44,8 +44,7 @@ export const step06: OnboardingStep = { condition: { kind: 'event_bus', event: 'agent:attached_to_browser' }, timeoutMs: 90000, }, - // Guide, don't commandeer: let the user tell the boss chat what to do with - // the helper, in their own words, instead of auto-typing + sending for them. + // Guide, don't commandeer: let the user tell the boss chat what to do with the helper, in their own words, instead of auto-typing + sending for them. { kind: 'move_to', target: S.chatInput }, { kind: 'popup', diff --git a/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts b/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts index f95b1210..6524546a 100644 --- a/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts +++ b/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts @@ -33,9 +33,7 @@ export const step08: OnboardingStep = { timeoutMs: 60000, }, { kind: 'delay', ms: 350 }, - // Guide, don't commandeer: point at the chat input and let the user describe - // their OWN app. We never type a canned prompt or auto-send, so the tour - // doesn't spend a run building something the user didn't choose. + // Guide, don't commandeer: point at the chat input and let the user describe their OWN app. We never type a canned prompt or auto-send, so the tour doesn't spend a run building something the user didn't choose. { kind: 'move_to', target: S.chatInput }, { kind: 'popup', diff --git a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts index 209b87be..33c040ac 100644 --- a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts +++ b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts @@ -1,8 +1,4 @@ -// Onboarding is a playground, not homework: every roadmap step is freely -// explorable in any order. A linear FEATURE_CHAIN used to gate each step on -// finishing the one above it (the 🔒 "Finish the step above" teasers); that read -// as a chore, so the gating is gone and nothing is locked. The exported shapes -// are kept so the panel/roadmap callers don't change. +// Onboarding is a playground, not homework: every roadmap step is freely explorable in any order. A linear FEATURE_CHAIN used to gate each step on finishing the one above it (the 🔒 "Finish the step above" teasers); that read as a chore, so the gating is gone and nothing is locked. The exported shapes are kept so the panel/roadmap callers don't change. import { useMemo } from 'react'; import type { RootState } from '@/shared/state/store'; diff --git a/frontend/src/app/components/editor/RichPromptEditor.tsx b/frontend/src/app/components/editor/RichPromptEditor.tsx index 7ef2b468..05f14028 100644 --- a/frontend/src/app/components/editor/RichPromptEditor.tsx +++ b/frontend/src/app/components/editor/RichPromptEditor.tsx @@ -149,16 +149,12 @@ const RichPromptEditor: React.FC = ({ if (result) { setPicker(result); } else { - // See ChatInput: bail when already hidden to avoid a per-keystroke - // re-render of the whole editor on every keypress. + // See ChatInput: bail when already hidden to avoid a per-keystroke re-render of the whole editor on every keypress. setPicker((p) => p.visible ? { ...p, visible: false } : p); } }, []); - // See ChatInput.handleInput: paste skips the heavy DOM scans that - // paste can't invalidate (never adds skill pills, never starts a - // slash/at trigger). emitChange still runs because Modes settings - // is controlled and the parent needs the new value. + // See ChatInput.handleInput: paste skips the heavy DOM scans that paste can't invalidate (never adds skill pills, never starts a slash/at trigger). emitChange still runs because Modes settings is controlled and the parent needs the new value. const justPastedRef = useRef(false); const handleInput = useCallback(() => { diff --git a/frontend/src/app/components/editor/useDomElementSelector.ts b/frontend/src/app/components/editor/useDomElementSelector.ts index f1be7caf..bb4e857c 100644 --- a/frontend/src/app/components/editor/useDomElementSelector.ts +++ b/frontend/src/app/components/editor/useDomElementSelector.ts @@ -9,8 +9,7 @@ const SELECT_META_ATTR = 'data-select-meta'; const DRAG_SELECT_TYPES = ['agent-card', 'view-card', 'browser-card', 'workflow-card', 'settings-option'] as const; const DRAG_SELECTOR = DRAG_SELECT_TYPES.map((t) => `[${SELECT_ATTR}="${t}"]`).join(','); -// The Workflows app window is a full app surface, not a card you attach as -// context, so the selection tool never targets it (neither drag nor click). +// The Workflows app window is a full app surface, not a card you attach as context, so the selection tool never targets it (neither drag nor click). const NON_SELECTABLE_TYPES = new Set(['workflows-hub-card']); export interface OverlayState { @@ -415,9 +414,7 @@ export function useDomElementSelector(): DomSelectorState { const prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = 'none'; - // Escape just exits the tool: turn select mode off but leave the already - // attached elements alone. Capture + stopPropagation so it doesn't also - // clear the canvas selection while the tool is the thing in focus. + // Escape just exits the tool: turn select mode off but leave the already attached elements alone. Capture + stopPropagation so it doesn't also clear the canvas selection while the tool is the thing in focus. const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; e.preventDefault(); diff --git a/frontend/src/app/components/feedback/PixelBlast.tsx b/frontend/src/app/components/feedback/PixelBlast.tsx index 0ac2f8e7..8125fe11 100644 --- a/frontend/src/app/components/feedback/PixelBlast.tsx +++ b/frontend/src/app/components/feedback/PixelBlast.tsx @@ -1,9 +1,4 @@ -// Brand-tinted Bayer-dither pixel-blast loading background. Same shader as before, but -// the WebGL2 context is now a MODULE-LEVEL SINGLETON: created once, the canvas is just -// reparented into whichever placeholder is mounted, and loseContext() is NEVER called. -// The old per-mount create+destroy churned GL contexts faster than the GPU could recycle -// them and killed the renderer under app-switch spam (worst with no chat anchoring the -// GPU process); one long-lived context keeps the look with zero churn. Same props. +// Brand-tinted Bayer-dither pixel-blast loading background. Same shader as before, but the WebGL2 context is now a MODULE-LEVEL SINGLETON: created once, the canvas is just reparented into whichever placeholder is mounted, and loseContext() is NEVER called. The old per-mount create+destroy churned GL contexts faster than the GPU could recycle them and killed the renderer under app-switch spam (worst with no chat anchoring the GPU process); one long-lived context keeps the look with zero churn. Same props. import React, { useEffect, useRef } from 'react'; diff --git a/frontend/src/app/components/overlays/TrustedFilePatterns.tsx b/frontend/src/app/components/overlays/TrustedFilePatterns.tsx index 8c532844..d7bb8571 100644 --- a/frontend/src/app/components/overlays/TrustedFilePatterns.tsx +++ b/frontend/src/app/components/overlays/TrustedFilePatterns.tsx @@ -8,11 +8,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; const TRUSTED_API = `${API_BASE}/tools/trusted-sensitive-paths`; -// Mirrors the backend _SENSITIVE_PATH_INFO mapping. Kept here intentionally -// (rather than fetched) because the user-facing label is the only part the -// settings page renders, and a static dictionary keeps the page snappy and -// works offline. If a pattern is unknown (older backend), fall back to the -// raw pattern string. +// Mirrors the backend _SENSITIVE_PATH_INFO mapping. Kept here intentionally (rather than fetched) because the user-facing label is the only part the settings page renders, and a static dictionary keeps the page snappy and works offline. If a pattern is unknown (older backend), fall back to the raw pattern string. const PATTERN_LABELS: Record = { '*/.ssh': 'SSH folder (~/.ssh)', '*/.ssh/*': 'SSH folder (~/.ssh)', @@ -75,9 +71,7 @@ export const TrustedFilePatterns: React.FC = () => { } }, [patterns, load]); - // Hide the whole section until the user actually has trusted patterns; - // an empty "no patterns yet" card was just visual bloat for the 99% case. - // The approval-time checkbox is what teaches the user this feature exists. + // Hide the whole section until the user actually has trusted patterns; an empty "no patterns yet" card was just visual bloat for the 99% case. The approval-time checkbox is what teaches the user this feature exists. if (!patterns || patterns.length === 0) return null; return ( diff --git a/frontend/src/app/components/share/ImportDigest.tsx b/frontend/src/app/components/share/ImportDigest.tsx index 01faccb6..f06ee282 100644 --- a/frontend/src/app/components/share/ImportDigest.tsx +++ b/frontend/src/app/components/share/ImportDigest.tsx @@ -1,10 +1,4 @@ -// The "digest" flash that plays where you drop a .swarm: a brand-tinted pixel -// blast that radiates from the drop point all the way to the corners, thinning -// out and dimming as it travels so the edges dissolve instead of ending in a -// box. Plain Canvas2D on ONE pooled, full-viewport canvas (reusing PixelBlast's -// shared WebGL context would fight an app's loading animation, and WebGL-context -// churn is the exact thing that crashed the GPU process). play() refuses to -// start while a burst is running, so drop-spam can never pile up work. +// The "digest" flash that plays where you drop a .swarm: a brand-tinted pixel blast that radiates from the drop point all the way to the corners, thinning out and dimming as it travels so the edges dissolve instead of ending in a box. Plain Canvas2D on ONE pooled, full-viewport canvas (reusing PixelBlast's shared WebGL context would fight an app's loading animation, and WebGL-context churn is the exact thing that crashed the GPU process). play() refuses to start while a burst is running, so drop-spam can never pile up work. import React, { forwardRef, useImperativeHandle, useRef } from 'react'; export interface DigestHandle { @@ -90,8 +84,7 @@ const ImportDigest = forwardRef(({ color = '#c const band = 1 - (ring - dist) / BAND; // brightest at the leading edge const distFrac = dist / maxDist; // 0 at origin, 1 at far corner const d = dither(gx, gy); - // Sparser the further out: distant cells need a high dither value to - // appear at all, so the wave frays into scattered pixels near the edges. + // Sparser the further out: distant cells need a high dither value to appear at all, so the wave frays into scattered pixels near the edges. if (d < distFrac * 0.85) continue; const a = band * (1 - distFrac * 0.6) * (1 - t * 0.2) * (0.4 + 0.6 * d) * ALPHA_CAP; if (a <= 0.02) continue; diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index 7cefbe97..8db0cb7e 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -1,7 +1,4 @@ -// The one global import affordance. Drop a .swarm anywhere (or pick it): a -// GPU-safe pixel "digest" flash plays where you dropped it WHILE the preflight -// runs underneath, then it resolves straight into the import for safe bundles or -// a short confirm for ones that carry code/actions. Mount once near the app root. +// The one global import affordance. Drop a .swarm anywhere (or pick it): a GPU-safe pixel "digest" flash plays where you dropped it WHILE the preflight runs underneath, then it resolves straight into the import for safe bundles or a short confirm for ones that carry code/actions. Mount once near the app root. import React, { useCallback, useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Fade from '@mui/material/Fade'; @@ -32,8 +29,7 @@ function looksImportable(name: string): boolean { return n.endsWith('.swarm') || n.endsWith('.md') || n.endsWith('.zip'); } -// A bundle needs a confirm only if it can run code (an app) or wants actions -// connected; everything else is inert data and imports straight away. +// A bundle needs a confirm only if it can run code (an app) or wants actions connected; everything else is inert data and imports straight away. function needsConfirm(pf: ImportPreflight): boolean { const s = pf.summary; const hasApp = s.root.type === 'app' || s.includes.some((i) => i.type === 'app'); diff --git a/frontend/src/app/components/share/ImportModal.tsx b/frontend/src/app/components/share/ImportModal.tsx index 08d6c3d4..46b6776b 100644 --- a/frontend/src/app/components/share/ImportModal.tsx +++ b/frontend/src/app/components/share/ImportModal.tsx @@ -1,7 +1,4 @@ -// Confirmation surface shown only for bundles that carry something with a -// consequence (an app that runs code, or actions that must be connected). Safe -// bundles never reach here; the entry point auto-imports them. This is purely -// presentational: the entry point owns preflight, commit, and navigation. +// Confirmation surface shown only for bundles that carry something with a consequence (an app that runs code, or actions that must be connected). Safe bundles never reach here; the entry point auto-imports them. This is purely presentational: the entry point owns preflight, commit, and navigation. import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; diff --git a/frontend/src/app/components/share/IncludesList.tsx b/frontend/src/app/components/share/IncludesList.tsx index a1d13824..7e9a8090 100644 --- a/frontend/src/app/components/share/IncludesList.tsx +++ b/frontend/src/app/components/share/IncludesList.tsx @@ -1,8 +1,4 @@ -// The "what's inside this bundle" panel, shared by the Share and Import modals. -// Kept deliberately spare: the bundle's name already lives in the modal title, so -// here it's just one line of type + counts, the requirements as small icon chips, -// and an optional expand for the full contents. No boxes, the modal's whitespace -// does the grouping. +// The "what's inside this bundle" panel, shared by the Share and Import modals. Kept deliberately spare: the bundle's name already lives in the modal title, so here it's just one line of type + counts, the requirements as small icon chips, and an optional expand for the full contents. No boxes, the modal's whitespace does the grouping. import React, { useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; diff --git a/frontend/src/app/components/share/PublishModal.tsx b/frontend/src/app/components/share/PublishModal.tsx index d887c716..d575af41 100644 --- a/frontend/src/app/components/share/PublishModal.tsx +++ b/frontend/src/app/components/share/PublishModal.tsx @@ -1,7 +1,4 @@ -// Publish an app to {slug}.openswarm.host. Flow: scan the code (AST + an aux-LLM -// pass, on the user's own creds) -> if findings, show them with Cancel/Fix/Publish -// Anyway -> build + upload -> show the live link. Already-published apps open -// straight to the manage view (visit / copy / unpublish). +// Publish an app to {slug}.openswarm.host. Flow: scan the code (AST + an aux-LLM pass, on the user's own creds) -> if findings, show them with Cancel/Fix/Publish Anyway -> build + upload -> show the live link. Already-published apps open straight to the manage view (visit / copy / unpublish). import React, { useCallback, useEffect, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; @@ -34,9 +31,7 @@ interface Props { type Phase = 'scanning' | 'review' | 'publishing' | 'done' | 'error'; -// A build can take up to ~3 minutes on a cold node_modules. Stay silent for the -// fast common case, then fade in an honest hint after 10s so a slow build doesn't -// read as a hang (matches the SlowHint pattern in ChatInputOverlays). +// A build can take up to ~3 minutes on a cold node_modules. Stay silent for the fast common case, then fade in an honest hint after 10s so a slow build doesn't read as a hang (matches the SlowHint pattern in ChatInputOverlays). const SlowHint: React.FC<{ active: boolean; color: string }> = ({ active, color }) => { const [show, setShow] = useState(false); useEffect(() => { diff --git a/frontend/src/app/components/share/ShareButton.tsx b/frontend/src/app/components/share/ShareButton.tsx index 1e772ed8..fb70c484 100644 --- a/frontend/src/app/components/share/ShareButton.tsx +++ b/frontend/src/app/components/share/ShareButton.tsx @@ -1,7 +1,4 @@ -// The reusable top-right Share affordance. Drop it on any modality's surface. -// 'icon' is the Anthropic-style header icon; 'menuItem' is for a sidebar "..." -// overflow menu. Click always stops propagation so card/header parents that own -// their own onClick don't also fire. +// The reusable top-right Share affordance. Drop it on any modality's surface. 'icon' is the Anthropic-style header icon; 'menuItem' is for a sidebar "..." overflow menu. Click always stops propagation so card/header parents that own their own onClick don't also fire. import React, { useState } from 'react'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; diff --git a/frontend/src/app/components/share/ShareModal.tsx b/frontend/src/app/components/share/ShareModal.tsx index c547d61d..0b571459 100644 --- a/frontend/src/app/components/share/ShareModal.tsx +++ b/frontend/src/app/components/share/ShareModal.tsx @@ -1,5 +1,4 @@ -// Anthropic-style Share modal. v1 ships one real action, Download .swarm; the -// "Create share link" row is shown but disabled (that hosted-link flow is v2). +// Anthropic-style Share modal. v1 ships one real action, Download .swarm; the "Create share link" row is shown but disabled (that hosted-link flow is v2). import React, { useCallback, useEffect, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; diff --git a/frontend/src/app/components/share/publishApi.ts b/frontend/src/app/components/share/publishApi.ts index 80da81c1..8c5c3504 100644 --- a/frontend/src/app/components/share/publishApi.ts +++ b/frontend/src/app/components/share/publishApi.ts @@ -1,7 +1,4 @@ -// Fetch helpers for the app-publishing endpoints. The global interceptor in -// shared/config.ts attaches the bearer, so we never set it here. The /publish -// endpoints return a result object (ok / blocked / error) rather than HTTP errors -// for the normal flow, so callers inspect the body. +// Fetch helpers for the app-publishing endpoints. The global interceptor in shared/config.ts attaches the bearer, so we never set it here. The /publish endpoints return a result object (ok / blocked / error) rather than HTTP errors for the normal flow, so callers inspect the body. import { API_BASE } from '@/shared/config'; import { ReviewSummary } from './shareTypes'; diff --git a/frontend/src/app/components/share/shareApi.ts b/frontend/src/app/components/share/shareApi.ts index daa244b1..d025c763 100644 --- a/frontend/src/app/components/share/shareApi.ts +++ b/frontend/src/app/components/share/shareApi.ts @@ -1,7 +1,4 @@ -// Thin fetch helpers for the .swarm endpoints. The global interceptor in -// shared/config.ts attaches the bearer token, so we never set it here. Errors -// surface the backend's short detail message (those are already user-facing) or -// a friendly fallback; callers translate to a toast. +// Thin fetch helpers for the .swarm endpoints. The global interceptor in shared/config.ts attaches the bearer token, so we never set it here. Errors surface the backend's short detail message (those are already user-facing) or a friendly fallback; callers translate to a toast. import { API_BASE } from '@/shared/config'; import { diff --git a/frontend/src/app/components/share/shareTypes.ts b/frontend/src/app/components/share/shareTypes.ts index 271410e7..737efcf8 100644 --- a/frontend/src/app/components/share/shareTypes.ts +++ b/frontend/src/app/components/share/shareTypes.ts @@ -1,5 +1,4 @@ -// Shared types for the .swarm share/import UI. The *Response shapes mirror the -// backend pydantic models in backend/apps/swarm/models.py; keep them in sync. +// Shared types for the .swarm share/import UI. The *Response shapes mirror the backend pydantic models in backend/apps/swarm/models.py; keep them in sync. export type ShareKind = 'skill' | 'app' | 'workflow' | 'dashboard'; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 0a9956d2..53dd106a 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -79,42 +79,24 @@ const CONTEXT_WINDOWS: Record = { haiku: 200_000, }; -// Only a fallback for never-rendered items; real heights are measured once on -// screen. +// Only a fallback for never-rendered items; real heights are measured once on screen. const RENDER_ITEM_ESTIMATED_HEIGHT = 140; -// Conservative estimate for an unmeasured tool row: tool groups/pairs render -// collapsed (~40-50px) far more often than expanded. Leaning low keeps scrollHeight -// (and the scrollbar thumb) from jumping when a tool row measures shorter. +// Conservative estimate for an unmeasured tool row: tool groups/pairs render collapsed (~40-50px) far more often than expanded. Leaning low keeps scrollHeight (and the scrollbar thumb) from jumping when a tool row measures shorter. const COLLAPSED_TOOL_ROW_HEIGHT = 44; -// How many screens of real content to keep mounted on EACH side of the viewport. -// Beyond it, items unmount and are replaced by a measured-height spacer, so -// render/memory stays bounded no matter how long the transcript is. +// How many screens of real content to keep mounted on EACH side of the viewport. Beyond it, items unmount and are replaced by a measured-height spacer, so render/memory stays bounded no matter how long the transcript is. const WINDOW_BUFFER_SCREENS_PER_SIDE = 3; -// Below this item count the transcript renders WHOLE, no spacers, no windowing. -// Virtualization only earns its keep on huge chats; on a normal chat the -// spacer-height recompute just fights the scroll position (the "jumps up and -// down" glitch), so we skip it entirely until a chat is genuinely long. +// Below this item count the transcript renders WHOLE, no spacers, no windowing. Virtualization only earns its keep on huge chats; on a normal chat the spacer-height recompute just fights the scroll position (the "jumps up and down" glitch), so we skip it entirely until a chat is genuinely long. const WINDOW_MIN_ITEMS = 60; -// Floor on the mounted item count so a single very tall item can't strand us with -// an effectively empty window. +// Floor on the mounted item count so a single very tall item can't strand us with an effectively empty window. const MIN_WINDOW_BUFFER_ITEMS = 6; -// Bootstrap count for the initial bottom-anchored slice (on open and on -// scroll-to-bottom): enough rows to cover the viewport + buffer using the same -// row-height estimate the solver uses, floored. It's only a seed — the pixel -// solver (computeDesiredWindow) refines the window to exact from measured heights -// on the next frame, so this never needs to be precise. +// Bootstrap count for the initial bottom-anchored slice (on open and on scroll-to-bottom): enough rows to cover the viewport + buffer using the same row-height estimate the solver uses, floored. It's only a seed — the pixel solver (computeDesiredWindow) refines the window to exact from measured heights on the next frame, so this never needs to be precise. function initialSeedItems(viewportHeight: number): number { const fillPx = (1 + WINDOW_BUFFER_SCREENS_PER_SIDE) * Math.max(1, viewportHeight); return Math.max(MIN_WINDOW_BUFFER_ITEMS, Math.ceil(fillPx / RENDER_ITEM_ESTIMATED_HEIGHT)); } -// Pure window solver: given the current scroll position and a per-index height -// accessor (measured where known, estimated otherwise), return the [start, end) -// slice of render items that should be mounted. The buffer is measured in PIXELS -// (N screens of real content on each side of the viewport), not item count, so a -// few very tall messages can't blow the mounted set up to the whole transcript. -// A huge viewport naturally yields start=0/end=total (mount all). +// Pure window solver: given the current scroll position and a per-index height accessor (measured where known, estimated otherwise), return the [start, end) slice of render items that should be mounted. The buffer is measured in PIXELS (N screens of real content on each side of the viewport), not item count, so a few very tall messages can't blow the mounted set up to the whole transcript. A huge viewport naturally yields start=0/end=total (mount all). function computeDesiredWindow( scrollTop: number, clientHeight: number, @@ -143,8 +125,7 @@ function computeDesiredWindow( } if (start === -1) start = Math.max(0, total - 1); end = Math.min(total, Math.max(end, start + 1)); - // Always keep at least a small floor of items mounted around the viewport so a - // single under-measured item can't strand us with an empty window. + // Always keep at least a small floor of items mounted around the viewport so a single under-measured item can't strand us with an empty window. if (end - start < MIN_WINDOW_BUFFER_ITEMS) { start = Math.max(0, Math.min(start, end - MIN_WINDOW_BUFFER_ITEMS)); } @@ -157,11 +138,7 @@ function stringifyContent(content: any): string { return JSON.stringify(content); } -// Content-aware height estimate for a render item that has never been measured. -// Tool rows and tiny system/thinking rows keep the flat fallback; message bubbles -// scale with their FULL text length (messages render in full once on-screen, so -// the estimate matches both the rendered bubble and MessageBubble's placeholder -// fallback). +// Content-aware height estimate for a render item that has never been measured. Tool rows and tiny system/thinking rows keep the flat fallback; message bubbles scale with their FULL text length (messages render in full once on-screen, so the estimate matches both the rendered bubble and MessageBubble's placeholder fallback). function estimateItemHeight(item: RenderItem, viewportWidth: number): number { if (isToolGroup(item) || isToolPair(item)) return COLLAPSED_TOOL_ROW_HEIGHT; const msg = item as AgentMessage; @@ -176,8 +153,7 @@ const thinkingShimmerKeyframes = ` } `; -// Pick a label deterministically per session-turn so the pill has variety -// without flickering between renders. Shared list with MessageBubble. +// Pick a label deterministically per session-turn so the pill has variety without flickering between renders. Shared list with MessageBubble. function streamingLabelFor(seedKey: string | undefined): string { if (!seedKey) return THINKING_LABELS[0].live; let h = 0; @@ -191,8 +167,7 @@ const ThinkingBubble: React.FC<{ label?: string | null; seedKey?: string }> = ({ const c = useClaudeTokens(); const shimmerBase = c.text.tertiary; const shimmerHighlight = c.text.primary; - // Aux-LLM label wins; otherwise pick a quirky verb keyed off seedKey - // so different sessions / turns show different verbs without flicker. + // Aux-LLM label wins; otherwise pick a quirky verb keyed off seedKey so different sessions / turns show different verbs without flicker. const display = label ? `${label}…` : `${streamingLabelFor(seedKey)}…`; return ( @@ -253,17 +228,13 @@ interface AgentChatProps { onDismissGlow?: () => void; initialContextPaths?: ContextPath[]; onBranch?: (newSessionId: string) => void; - // Set when this chat is the workflow build/edit agent: the out-of-tokens card - // then warns that switching models here also changes the workflow's run model. + // Set when this chat is the workflow build/edit agent: the out-of-tokens card then warns that switching models here also changes the workflow's run model. workflowEditId?: string; - // View-only transcript (e.g. the Run Monitor): renders messages + tool calls - // but no composer, so the session can't be typed into. + // View-only transcript (e.g. the Run Monitor): renders messages + tool calls but no composer, so the session can't be typed into. readOnly?: boolean; // One-shot text to drop into the composer (e.g. a run attached as context). prefillPrompt?: string; - // A workflow run attached as a removable context chip above the composer; while - // present, each send routes through onSendRunQuestion so the run's transcript - // rides along as hidden context for that turn. + // A workflow run attached as a removable context chip above the composer; while present, each send routes through onSendRunQuestion so the run's transcript rides along as hidden context for that turn. runContext?: WorkflowsRunContext; onClearRunContext?: () => void; onSendRunQuestion?: (prompt: string, runId: string) => Promise; @@ -280,10 +251,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }; const { id: routeId } = useParams<{ id: string }>(); const id = sessionIdProp || routeId; - // A card linked as a workflow sidecar (Test Agent, or a watched run) swaps - // its composer for a Force Stop button: continuing the chat is meaningless, - // but killing the run is the common need. Once a Test Agent finishes, the - // button flips to a green "close" (see workflow_test_state + ForceStopAgentBar). + // A card linked as a workflow sidecar (Test Agent, or a watched run) swaps its composer for a Force Stop button: continuing the chat is meaningless, but killing the run is the common need. Once a Test Agent finishes, the button flips to a green "close" (see workflow_test_state + ForceStopAgentBar). const linkedSidecar = useAppSelector((s) => { const found = Object.values(s.workflows.openCards).find( (cd) => cd.sidecarSessionId === id && (cd.sidecarKind === 'testing' || cd.sidecarKind === 'watching'), @@ -294,11 +262,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }, shallowEqual); const linkedWorkflowId = linkedSidecar?.workflowId ?? null; const isStoppableSidecar = !!linkedWorkflowId; - // A live workflow run being watched owns pause/resume from its workflow - // card, so the chat's own "Resume Agent Response" bubble is redundant and - // would go stale against the card's Resume. Suppress it for any workflow-run - // sidecar, not just the fragile exact "watching" value. Test-run sidecars - // keep their chat-level resume behavior. + // A live workflow run being watched owns pause/resume from its workflow card, so the chat's own "Resume Agent Response" bubble is redundant and would go stale against the card's Resume. Suppress it for any workflow-run sidecar, not just the fragile exact "watching" value. Test-run sidecars keep their chat-level resume behavior. const isWorkflowRunSidecar = useAppSelector((s) => { if (!id) return false; for (const cd of Object.values(s.workflows.openCards)) { @@ -334,9 +298,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (s.includes('/')) s = s.split('/').pop() || s; return s; }, [modelsByProvider]); - // Used by the "too many connected apps for Haiku" warning rendered above - // ChatInput. Each connected MCP adds a meaningful chunk of tool-schema - // tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them. + // Used by the "too many connected apps for Haiku" warning rendered above ChatInput. Each connected MCP adds a meaningful chunk of tool-schema tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them. const toolItems = useAppSelector((state) => state.tools.items); const scrollContainerRef = useRef(null); const lastVisibleItemRef = useRef(null); @@ -367,18 +329,15 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const [preSendActivityLabel, setPreSendActivityLabel] = useState(null); const [activatingMcp, setActivatingMcp] = useState(null); const [activateError, setActivateError] = useState(null); - // Holds the last non-empty suggestions so the docked banner's exit fade renders - // them instead of going blank the instant the array is cleared. + // Holds the last non-empty suggestions so the docked banner's exit fade renders them instead of going blank the instant the array is cleared. const mcpSnapshotRef = useRef>([]); const [mode, setMode] = useState('agent'); const [model, setModel] = useState('sonnet'); - // Workflow build chat only: brief "this model now runs the workflow" notice - // when the user switches models, so the run-model change isn't silent. + // Workflow build chat only: brief "this model now runs the workflow" notice when the user switches models, so the run-model change isn't silent. const [workflowModelNotice, setWorkflowModelNotice] = useState(null); const workflowModelNoticeTimer = useRef | null>(null); - // Read live in the stable handleSend/dispatchMessage closures without busting - // their memo (ChatInput leans on handleSend identity holding across renders). + // Read live in the stable handleSend/dispatchMessage closures without busting their memo (ChatInput leans on handleSend identity holding across renders). const runContextRef = useRef(runContext); runContextRef.current = runContext; const onSendRunQuestionRef = useRef(onSendRunQuestion); @@ -403,21 +362,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (!id || isDraft) return; let cancelled = false; let ws: ReturnType | null = null; - // Order matters: hydrate the persisted message list from REST FIRST, - // THEN connect the WS. The WS resume protocol replays buffered - // events starting at last_seq=0, which includes every stream_* - // event for messages that finished before the disconnect. The - // replay-skip guard in WebSocketManager._messageAlreadyComplete - // checks `session.messages` to decide whether to drop deltas , so - // if we connect first, the slice is empty when the replay arrives, - // the guard returns false, and the user sees the chat type itself - // out again. Awaiting fetchSession before connect makes the slice - // authoritative before any replay event lands. + // Order matters: hydrate the persisted message list from REST FIRST, THEN connect the WS. The WS resume protocol replays buffered events starting at last_seq=0, which includes every stream_* event for messages that finished before the disconnect. The replay-skip guard in WebSocketManager._messageAlreadyComplete checks `session.messages` to decide whether to drop deltas, so if we connect first, the slice is empty when the replay arrives, the guard returns false, and the user sees the chat type itself out again. Awaiting fetchSession before connect makes the slice authoritative before any replay event lands. (async () => { - // The await exists so the slice isn't EMPTY at replay time. A warm store - // (remount after a hop) already satisfies that, so connect immediately and - // let the fetch reconcile in the background; awaiting serialized a slow - // round trip in front of the live stream on every reopen. + // The await exists so the slice isn't EMPTY at replay time. A warm store (remount after a hop) already satisfies that, so connect immediately and let the fetch reconcile in the background; awaiting serialized a slow round trip in front of the live stream on every reopen. const warm = !!store.getState().agents.sessions[id]?.messages?.length; if (warm) { dispatch(fetchSession(id)); @@ -425,14 +372,11 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose try { await dispatch(fetchSession(id)); } catch { - // Even if the REST hydrate fails, still connect , the WS resume - // protocol can hydrate from buffered events as a fallback. + // Even if the REST hydrate fails, still connect, the WS resume protocol can hydrate from buffered events as a fallback. } } if (cancelled) return; - // acquireSessionWs reuses a still-open socket kept alive from the last hop, - // so an active agent's stream resumes with no reconnect handshake. connect() - // is a no-op when the reused socket is already open. + // acquireSessionWs reuses a still-open socket kept alive from the last hop, so an active agent's stream resumes with no reconnect handshake. connect() is a no-op when the reused socket is already open. ws = acquireSessionWs(id); ws.connect(); wsRef.current = ws; @@ -477,12 +421,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const config: Record = { model, mode }; if (session?.system_prompt) config.system_prompt = session.system_prompt; if (session?.target_directory) config.target_directory = session.target_directory; - // Carry the draft's dashboard so the launched session stays ON this dashboard; without it the - // session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes - // the instant you send (looked like "the chat quit when I clicked an option"). + // Carry the draft's dashboard so the launched session stays ON this dashboard; without it the session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes the instant you send (looked like "the chat quit when I clicked an option"). if (session?.dashboard_id) config.dashboard_id = session.dashboard_id; - // Editing an existing app: bind the launch to it so the backend edits in - // place instead of seeding a duplicate empty app (App Builder mode only). + // Editing an existing app: bind the launch to it so the backend edits in place instead of seeding a duplicate empty app (App Builder mode only). if (msg.selectedAppIds?.length) config.selected_app_output_ids = msg.selectedAppIds; dispatch( launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds, selectedSettingIds: msg.selectedSettingIds }) @@ -496,8 +437,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } }); } else if (msg.attachedRunId && onSendRunQuestionRef.current) { - // Run-context question: the backend folds the run transcript into this one - // turn and echoes the user bubble + answer over WS, so no optimistic thunk. + // Run-context question: the backend folds the run transcript into this one turn and echoes the user bubble + answer over WS, so no optimistic thunk. onSendRunQuestionRef.current(msg.prompt, msg.attachedRunId).catch(() => setAwaitingResponse(false)); } else { if (msg.selectedBrowserIds?.length) { @@ -559,9 +499,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage, isWorkflowRunSidecar]); - // A reload remounts past the live running->stopped transition that first shows - // the resume button, so re-derive it once from the persisted 'stopped' status - // (transcript-gated so a cleared chat can't resurrect it). + // A reload remounts past the live running->stopped transition that first shows the resume button, so re-derive it once from the persisted 'stopped' status (transcript-gated so a cleared chat can't resurrect it). const resumeHydratedRef = useRef(false); useEffect(() => { if (resumeHydratedRef.current) return; @@ -571,19 +509,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } }, [session?.status, session?.messages?.length]); - // Idle reconcile: if the session has been 'running' for 5s with no - // WebSocket activity (no new messages, no streaming updates), do a - // single GET to fetch the real status from the backend. Catches the - // case where the completion WebSocket event was dropped (network blip, - // sleep/wake, SDK subprocess dying). Resets on every activity signal - // so it never fires during normal streaming. + // Idle reconcile: if the session has been 'running' for 5s with no WebSocket activity (no new messages, no streaming updates), do a single GET to fetch the real status from the backend. Catches the case where the completion WebSocket event was dropped (network blip, sleep/wake, SDK subprocess dying). Resets on every activity signal so it never fires during normal streaming. const reconcileTimer = useRef | null>(null); const messageCount = session?.messages?.length ?? 0; - // Subscribe only to the streaming MESSAGE ID (stable across the 30Hz - // delta updates), never to the content. The actual streaming text - // renders inside the leaf below, which subscribes to - // the content itself. This keeps AgentChat's render and useEffects - // dormant during streaming; only the bubble updates per delta. + // Subscribe only to the streaming MESSAGE ID (stable across the 30Hz delta updates), never to the content. The actual streaming text renders inside the leaf below, which subscribes to the content itself. This keeps AgentChat's render and useEffects dormant during streaming; only the bubble updates per delta. const streamingMessageId = useAppSelector((s) => id ? s.streaming.bySession[id]?.id ?? null : null); const hasStreaming = !!streamingMessageId; @@ -622,10 +551,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const SCROLL_THRESHOLD = 50; - // Reserved pixel height for a render item: the measured height once we have one, - // otherwise a content-aware estimate (cached per id). The spacer math and the - // window solver both go through this so unmounted spacers, freshly-mounted - // placeholders, and the real rendered bubble all reserve the same space. + // Reserved pixel height for a render item: the measured height once we have one, otherwise a content-aware estimate (cached per id). The spacer math and the window solver both go through this so unmounted spacers, freshly-mounted placeholders, and the real rendered bubble all reserve the same space. const reservedHeightForItem = useCallback((item: RenderItem | undefined): number => { if (!item) return RENDER_ITEM_ESTIMATED_HEIGHT; const measured = itemHeightsRef.current.get(item.id); @@ -637,31 +563,22 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return est; }, []); - // Measured-or-estimated pixel height of render item at `index`, for the window - // solver (reads the renderItems ref so it is valid inside rAF callbacks). + // Measured-or-estimated pixel height of render item at `index`, for the window solver (reads the renderItems ref so it is valid inside rAF callbacks). const heightOf = useCallback((index: number): number => { return reservedHeightForItem(renderItemsRef.current[index]); }, [reservedHeightForItem]); - // Solve the mounted window from the live scroll position and push it to state - // when it changes. Scroll position itself is preserved by the container's - // overflow-anchor plus the measured-height spacers, so we never touch - // scrollTop here. Following (pinned to bottom) always keeps the newest item. + // Solve the mounted window from the live scroll position and push it to state when it changes. Scroll position itself is preserved by the container's overflow-anchor plus the measured-height spacers, so we never touch scrollTop here. Following (pinned to bottom) always keeps the newest item. const applyWindowFromScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; if (!initialBottomScrollSettledRef.current) return; const total = renderItemsLengthRef.current; - // Below the windowing threshold the whole transcript is mounted; recomputing - // a window here would only churn the spacers and shift scroll. Leave it alone. + // Below the windowing threshold the whole transcript is mounted; recomputing a window here would only churn the spacers and shift scroll. Leave it alone. if (total < WINDOW_MIN_ITEMS) return; const clientHeight = Math.max(1, el.clientHeight); const tightPx = WINDOW_BUFFER_SCREENS_PER_SIDE * clientHeight; - // Mount with the tight buffer, but keep already-mounted items until - // they drift a full extra screen past it. Without this, an item sitting - // right on the buffer edge flip-flops mounted/unmounted forever: mounting it - // shifts content above the viewport, overflow-anchor nudges scrollTop a few px, - // that re-runs the solver, which now excludes it, and round it goes. + // Mount with the tight buffer, but keep already-mounted items until they drift a full extra screen past it. Without this, an item sitting right on the buffer edge flip-flops mounted/unmounted forever: mounting it shifts content above the viewport, overflow-anchor nudges scrollTop a few px, that re-runs the solver, which now excludes it, and round it goes. const loosePx = tightPx + clientHeight; const tight = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, tightPx); const loose = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, loosePx); @@ -695,12 +612,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const updateViewport = () => { setViewportHeight(el.clientHeight); setViewportWidth(el.clientWidth); - // Width drives the char-per-line estimate; drop cached estimates so they - // recompute at the new width (measured heights are unaffected and kept). + // Width drives the char-per-line estimate; drop cached estimates so they recompute at the new width (measured heights are unaffected and kept). estimateCacheRef.current.clear(); - // Resize changes the budgets and how many items fit; re-solve the window - // off the current scroll position WITHOUT resetting it (only session / - // branch changes reset). overflow-anchor holds the visible content. + // Resize changes the budgets and how many items fit; re-solve the window off the current scroll position WITHOUT resetting it (only session / branch changes reset). overflow-anchor holds the visible content. scheduleWindowRecompute(); }; @@ -745,8 +659,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; isAtBottomRef.current = atBottom; setShowScrollButton(!atBottom); - // Slide the mounted window to follow the viewport (loads newer/older items - // and unloads ones that drifted past the buffer on either side). + // Slide the mounted window to follow the viewport (loads newer/older items and unloads ones that drifted past the buffer on either side). scheduleWindowRecompute(); }, [scheduleWindowRecompute]); @@ -755,13 +668,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const el = scrollContainerRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { - // Pinch-to-zoom (ctrl/meta + wheel) must reach the canvas viewport so - // the dashboard zooms when the cursor is over an agent's chat panel. - // Without this early-out the unconditional stopPropagation below kills - // ctrl+wheel and the canvas listener never fires. + // Pinch-to-zoom (ctrl/meta + wheel) must reach the canvas viewport so the dashboard zooms when the cursor is over an agent's chat panel. Without this early-out the unconditional stopPropagation below kills ctrl+wheel and the canvas listener never fires. if (e.ctrlKey || e.metaKey) return; - // Horizontal-dominant gestures must also reach the canvas so a sideways - // swipe pans the dashboard (chat has no horizontal scroll to absorb). + // Horizontal-dominant gestures must also reach the canvas so a sideways swipe pans the dashboard (chat has no horizontal scroll to absorb). if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; const atTop = el.scrollTop <= 0; const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; @@ -782,11 +691,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (!el) return; isAtBottomRef.current = true; setShowScrollButton(false); - // When scrolled far up the newest items are unmounted behind the bottom - // spacer (estimated height). Jump the window to the bottom slice so they - // actually mount, then pin across several frames: a single scrollTop=scrollHeight - // lands short because the spacer collapses and the freshly-mounted items - // replace their estimates with real measured heights, changing scrollHeight. + // When scrolled far up the newest items are unmounted behind the bottom spacer (estimated height). Jump the window to the bottom slice so they actually mount, then pin across several frames: a single scrollTop=scrollHeight lands short because the spacer collapses and the freshly-mounted items replace their estimates with real measured heights, changing scrollHeight. const total = renderItemsLengthRef.current; const start = Math.max(0, total - initialSeedItems(el.clientHeight)); windowStartRef.current = start; @@ -806,8 +711,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose scrollToBottomRafRef.current = requestAnimationFrame(pin); } else { scrollToBottomRafRef.current = null; - // Jump has settled: re-evaluate oversized message / block visibility - // synchronously so nothing now in view is stuck as a placeholder. + // Jump has settled: re-evaluate oversized message / block visibility synchronously so nothing now in view is stuck as a placeholder. c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT)); } }; @@ -818,10 +722,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const pinRafRef = useRef(null); const initialPinRafRef = useRef(null); const lastScrollHeightRef = useRef(0); - // Shared scroll-stick routine. Used both by the structural-events - // useEffect below (new message lands / stream starts/ends) and by - // StreamingBubble's onStreamGrew callback (per-delta growth). RAF + - // height-grew gate ensures we only set scrollTop when needed. + // Shared scroll-stick routine. Used both by the structural-events useEffect below (new message lands / stream starts/ends) and by StreamingBubble's onStreamGrew callback (per-delta growth). RAF + height-grew gate ensures we only set scrollTop when needed. const stickToBottomIfNeeded = useCallback(() => { if (!isAtBottomRef.current) return; if (scrollRafRef.current != null) return; @@ -838,28 +739,14 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }, []); useEffect(() => { stickToBottomIfNeeded(); - // Structural triggers only: a new message lands or a stream - // starts/ends. Streaming content updates trigger this via - // instead - // so AgentChat stays dormant during the 30Hz delta storm. + // Structural triggers only: a new message lands or a stream starts/ends. Streaming content updates trigger this via instead so AgentChat stays dormant during the 30Hz delta storm. }, [session?.messages.length, streamingMessageId, stickToBottomIfNeeded]); - // Stream-end re-stick. When a stream finishes, the live bubble (smooth-revealed - // text) is replaced by the committed bubble rendering FULL markdown with - // contentVisibility placeholders; as those resolve, Chromium's overflow-anchor - // re-anchors to an EARLIER element (the user message), yanking the view up to - // "the top of the user input". A single deferred scroll loses the race because - // that anchor shift fires an onScroll that flips isAtBottomRef false before we - // run. Fix: snapshot the "was following" intent the moment streaming stops - // (captured continuously during the stream, before any completion re-render), - // then pin to bottom across a short multi-frame window that OVERRIDES the - // layout-induced flip. A genuine user scroll-away (wheel/touch) during that - // window aborts the pin, honoring "unless the user scrolls up". + // Stream-end re-stick. When a stream finishes, the live bubble (smooth-revealed text) is replaced by the committed bubble rendering FULL markdown with contentVisibility placeholders; as those resolve, Chromium's overflow-anchor re-anchors to an EARLIER element (the user message), yanking the view up to "the top of the user input". A single deferred scroll loses the race because that anchor shift fires an onScroll that flips isAtBottomRef false before we run. Fix: snapshot the "was following" intent the moment streaming stops (captured continuously during the stream, before any completion re-render), then pin to bottom across a short multi-frame window that OVERRIDES the layout-induced flip. A genuine user scroll-away (wheel/touch) during that window aborts the pin, honoring "unless the user scrolls up". const prevStreamingIdRef = useRef(null); const wasFollowingRef = useRef(true); const pinAbortRef = useRef(false); - // Keep the follow-intent fresh while streaming so it's accurate at the instant - // the stream ends (handleScroll updates isAtBottomRef on every real scroll). + // Keep the follow-intent fresh while streaming so it's accurate at the instant the stream ends (handleScroll updates isAtBottomRef on every real scroll). if (streamingMessageId) wasFollowingRef.current = isAtBottomRef.current; useEffect(() => { const prev = prevStreamingIdRef.current; @@ -869,8 +756,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose pinAbortRef.current = false; const el = scrollContainerRef.current; if (!el) return; - // Abort the pin only on a deliberate scroll-away gesture, not the - // layout-induced onScroll the commit itself triggers. + // Abort the pin only on a deliberate scroll-away gesture, not the layout-induced onScroll the commit itself triggers. const onUserScrollAway = (e: Event) => { if ((e as WheelEvent).deltaY != null && (e as WheelEvent).deltaY < 0) pinAbortRef.current = true; // wheel up else if (e.type === 'touchmove') pinAbortRef.current = true; @@ -895,11 +781,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose return cleanup; }, [streamingMessageId]); - // A tool's live pill is already on screen when it commits, so re-running the - // mount reveal on the committed bubble flashes the exact same row. Remember the - // id that just stopped streaming for a beat and let that one bubble skip its - // entrance, so the hand-off is seamless. 500ms is slack for the commit render - // to land after the stream clears (they don't always arrive on the same frame). + // A tool's live pill is already on screen when it commits, so re-running the mount reveal on the committed bubble flashes the exact same row. Remember the id that just stopped streaming for a beat and let that one bubble skip its entrance, so the hand-off is seamless. 500ms is slack for the commit render to land after the stream clears (they don't always arrive on the same frame). const [justStreamedId, setJustStreamedId] = useState(null); const justStreamPrevRef = useRef(null); useEffect(() => { @@ -931,10 +813,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } }, []); - // useCallback so ChatInput's memo equality holds across AgentChat - // re-renders driven by unrelated session state. Captures agentBusy - // through the dependency so a stale "busy" closure doesn't ever route - // a message past the queue. + // useCallback so ChatInput's memo equality holds across AgentChat re-renders driven by unrelated session state. Captures agentBusy through the dependency so a stale "busy" closure doesn't ever route a message past the queue. const handleSend = useCallback( ( prompt: string, @@ -992,8 +871,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const handleStop = useCallback(() => { if (!id) return; - // A watched live workflow run mirrors the workflow card's Stop: fully stop the - // run, not just pause the agent. Test Agent + plain chats stop the session. + // A watched live workflow run mirrors the workflow card's Stop: fully stop the run, not just pause the agent. Test Agent + plain chats stop the session. if (linkedSidecar?.kind === 'watching' && linkedSidecar.runId) { dispatch(controlWorkflowRun({ runId: linkedSidecar.runId, action: 'stop' })); return; @@ -1001,9 +879,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose dispatch(stopAgent({ sessionId: id })); }, [id, dispatch, linkedSidecar]); - // Finished Test Agent card: drop the tether + remove this card, and either - // commit the workflow draft (Save, same as the edit card's "save now") or - // leave the draft untouched so the user keeps editing. + // Finished Test Agent card: drop the tether + remove this card, and either commit the workflow draft (Save, same as the edit card's "save now") or leave the draft untouched so the user keeps editing. const onTestContinueEditing = useCallback(() => { if (linkedWorkflowId) dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null })); if (id) dispatch(removeCard(id)); @@ -1127,12 +1003,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }, [id, dispatch, onBranch, session?.dashboard_id]); const contextEstimate = useMemo(() => { - // Prefer the live API-reported input token count once we have one - // (session.tokens.input includes the full request: messages + system + - // tool defs + cached prefix). That number is authoritative because - // Anthropic counts it against the context window. Before the first - // turn completes, fall back to a char/4 estimate of visible message - // content as a rough pre-send hint. + // Prefer the live API-reported input token count once we have one (session.tokens.input includes the full request: messages + system + tool defs + cached prefix). That number is authoritative because Anthropic counts it against the context window. Before the first turn completes, fall back to a char/4 estimate of visible message content as a rough pre-send hint. let limit = 0; for (const ms of Object.values(modelsByProvider)) { const hit = ms.find((m) => m.value === model); @@ -1150,11 +1021,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } const used = Math.round(totalChars / 4); return { used, limit }; - // Streaming content's contribution to the context estimate is no - // longer included here: we'd have to subscribe to the streaming - // text and re-run this sum on every painted character, defeating - // the whole point of isolating AgentChat from delta updates. The - // header gauge will catch up when stream_end commits the message. + // Streaming content's contribution to the context estimate is no longer included here: we'd have to subscribe to the streaming text and re-run this sum on every painted character, defeating the whole point of isolating AgentChat from delta updates. The header gauge will catch up when stream_end commits the message. }, [activeBranchMessages, session?.system_prompt, session?.tokens?.input, session?.context_window, streamingMessageId, model, modelsByProvider]); const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval'; @@ -1242,9 +1109,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose let start = windowStartRef.current; let end = windowEndRef.current; if (isAtBottomRef.current || end === 0) { - // Following the live tail: keep the newest item mounted and unload the - // oldest beyond a bounded recent slice so memory stays flat as the - // transcript grows. The pixel solver refines this seed on the next scroll. + // Following the live tail: keep the newest item mounted and unload the oldest beyond a bounded recent slice so memory stays flat as the transcript grows. The pixel solver refines this seed on the next scroll. end = total; start = Math.max(0, end - seed); } else { @@ -1257,8 +1122,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }, [id, renderItems, viewportHeight]); const total = renderItems.length; - // Small chats render whole (no windowing): forces the full slice so both spacer - // loops sum to 0, which removes the recompute-driven scroll jump entirely. + // Small chats render whole (no windowing): forces the full slice so both spacer loops sum to 0, which removes the recompute-driven scroll jump entirely. const windowingActive = total >= WINDOW_MIN_ITEMS; const safeWindowEnd = !windowingActive ? total : (windowEnd > 0 ? Math.min(windowEnd, total) : total); const safeWindowStart = !windowingActive ? 0 : Math.min(Math.max(0, windowStart), Math.max(0, safeWindowEnd - 1)); @@ -1274,8 +1138,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // Keep the ref the height estimator reads in sync with the live viewport width. viewportWidthRef.current = viewportWidth; - // Measure mounted item heights so the spacers that stand in for unmounted - // items keep the scrollbar geometry stable (no jump when unloading above). + // Measure mounted item heights so the spacers that stand in for unmounted items keep the scrollbar geometry stable (no jump when unloading above). React.useLayoutEffect(() => { const el = scrollContainerRef.current; if (!el) return; @@ -1295,10 +1158,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (changed) setHeightVersion((v) => v + 1); }); - // Spacers reserve the cumulative height of the unmounted items above/below the - // window. heightVersion gates recompute off the ref-held measurements; we index - // the render-scope renderItems directly so id->height stays correct on the - // frame the transcript changes. + // Spacers reserve the cumulative height of the unmounted items above/below the window. heightVersion gates recompute off the ref-held measurements; we index the render-scope renderItems directly so id->height stays correct on the frame the transcript changes. const topSpacerHeight = useMemo(() => { let h = 0; for (let i = 0; i < safeWindowStart; i++) h += reservedHeightForItem(renderItems[i]); @@ -1331,12 +1191,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } else { initialPinRafRef.current = null; initialBottomScrollSettledRef.current = true; - // The open slice is sized by item COUNT; now that we're settled and - // measured, trim it down to the pixel-based band so tall messages high - // in the slice unload instead of sitting fully rendered off-screen. + // The open slice is sized by item COUNT; now that we're settled and measured, trim it down to the pixel-based band so tall messages high in the slice unload instead of sitting fully rendered off-screen. scheduleWindowRecompute(); - // Re-evaluate visibility now the open jump has settled, so an oversized - // newest message isn't left stuck as a placeholder. + // Re-evaluate visibility now the open jump has settled, so an oversized newest message isn't left stuck as a placeholder. c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT)); } }; @@ -1472,9 +1329,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose gap: 1.5, px: 2, py: 1.5, - // No seam: the header is just a band of typography inside the chat - // panel; transparent bg + air carry it, no hairline. (An earlier - // bg.surface here read lighter than the body and pulled focus.) + // No seam: the header is just a band of typography inside the chat panel; transparent bg + air carry it, no hairline. (An earlier bg.surface here read lighter than the body and pulled focus.) bgcolor: 'transparent', }} > @@ -1508,21 +1363,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} {(() => { if (!(session.cost_usd > 0)) return null; - // The SDK reports a per-call $ figure regardless of how - // the request was routed. For requests that went through - // a subscription path, that figure is misleading , the - // user pays flat-rate. Show "subscription" instead in - // those cases. Show $ only when the call was actually - // metered (Anthropic API key, OpenAI API key, etc.). - // - // Model-id signals (these are short_name values from the - // BUILTIN_MODELS registry): - // - `*-api` → pinned Anthropic API key (METERED) - // - `*-cc` → pinned Claude Pro/Max via 9Router (sub) - // - plain sonnet/opus/haiku + openswarm-pro mode → Pro proxy (sub) - // - plain sonnet/opus/haiku + own_key mode → API key (METERED) - // - gpt-5.4* / gpt-5.3* → ChatGPT Plus/Pro via 9Router (sub) - // - gemini-* → Gemini Advanced via 9Router (sub) + // The SDK reports a per-call $ figure regardless of how the request was routed. For requests that went through a subscription path, that figure is misleading, the user pays flat-rate. Show "subscription" instead in those cases. Show $ only when the call was actually metered (Anthropic API key, OpenAI API key, etc.). Model-id signals (these are short_name values from the BUILTIN_MODELS registry): - `*-api` → pinned Anthropic API key (METERED) - `*-cc` → pinned Claude Pro/Max via 9Router (sub) - plain sonnet/opus/haiku + openswarm-pro mode → Pro proxy (sub) - plain sonnet/opus/haiku + own_key mode → API key (METERED) - gpt-5.4* / gpt-5.3* → ChatGPT Plus/Pro via 9Router (sub) - gemini-* → Gemini Advanced via 9Router (sub) const m = (session.model || '').toLowerCase(); const isApiRoute = m.endsWith('-api'); if (isApiRoute) { @@ -1620,30 +1461,14 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose height: '100%', overflow: 'auto', scrollbarGutter: 'stable', - // Top-aligned natural flow: messages start at the top and grow down - // (standard chat). The earlier flex-column + mt:auto bottom-anchor - // clustered short chats at the bottom under a big void, reading broken. + // Top-aligned natural flow: messages start at the top and grow down (standard chat). The earlier flex-column + mt:auto bottom-anchor clustered short chats at the bottom under a big void, reading broken. px: 2, py: 1, - // Smoothness bundle (perf-only , no behavior change): - // 1. overflow-anchor: auto , Chromium's native scroll - // anchoring keeps the viewport pinned to the user's - // visible content as siblings above/below resize. - // Eliminates the "transcript snaps back" feel during - // streaming and parallel tool fan-outs. Runs on the - // compositor thread, free. - // 2. contain: layout , tells the browser layout shifts - // inside this scroll container don't affect siblings - // outside it. Prevents reflow from cascading up to - // the dashboard layout when bubbles grow. - // 3. overscroll-behavior: contain , keeps over-scroll - // gestures from leaking up to the dashboard pan/zoom - // when the user hits the chat top/bottom. + // Smoothness bundle (perf-only, no behavior change): 1. overflow-anchor: auto, Chromium's native scroll anchoring keeps the viewport pinned to the user's visible content as siblings above/below resize. Eliminates the "transcript snaps back" feel during streaming and parallel tool fan-outs. Runs on the compositor thread, free. 2. contain: layout, tells the browser layout shifts inside this scroll container don't affect siblings outside it. Prevents reflow from cascading up to the dashboard layout when bubbles grow. 3. overscroll-behavior: contain, keeps over-scroll gestures from leaking up to the dashboard pan/zoom when the user hits the chat top/bottom. overflowAnchor: 'auto', contain: 'layout', overscrollBehavior: 'contain', - // Hidden until the user is in the chat: the thumb is transparent at - // rest and fades in on hover, so a resizing thumb never draws the eye. + // Hidden until the user is in the chat: the thumb is transparent at rest and fades in on hover, so a resizing thumb never draws the eye. '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-track': { background: 'transparent' }, '&::-webkit-scrollbar-thumb': { @@ -1666,8 +1491,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const isOutOfTokens = reason === 'out_of_tokens'; const title = isOutOfTokens ? 'Out of tokens' : isAuth ? 'Sign-in required' : 'Context full'; const primaryLabel = isOutOfTokens ? 'Got it' : isAuth ? 'Open Settings' : 'Start a fresh chat'; - // In the workflow build chat, switching models here also sets the - // workflow's scheduled run model, so spell that consequence out. + // In the workflow build chat, switching models here also sets the workflow's scheduled run model, so spell that consequence out. const message = isOutOfTokens && workflowEditId ? `${session.context_overflow.message} Whichever model you switch to here becomes the model this workflow runs on.` : session.context_overflow.message; @@ -1768,14 +1592,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose = ({ sessionId: sessionIdProp, onClose )} {(() => { - // Proactive Haiku-overflow warning. Each connected MCP adds - // a sizeable tools-schema chunk to every Claude request; - // Haiku 4.5's window is 5x smaller than Sonnet/Opus, so 5+ - // simultaneously-enabled MCPs reliably push a one-line - // message past the limit. We surface this BEFORE the user - // sends so they don't waste a turn on "Prompt is too long". + // Proactive Haiku-overflow warning. Each connected MCP adds a sizeable tools-schema chunk to every Claude request; Haiku 4.5's window is 5x smaller than Sonnet/Opus, so 5+ simultaneously-enabled MCPs reliably push a one-line message past the limit. We surface this BEFORE the user sends so they don't waste a turn on "Prompt is too long". const isHaiku = (model || '').toLowerCase().startsWith('haiku'); const enabledMcpCount = Object.values(toolItems).filter( (t) => t.enabled && t.mcp_config && Object.keys(t.mcp_config).length > 0, @@ -2363,13 +2175,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (!r.ok) { setActivateError(`Activation failed (${r.status})`); } else if (body?.status === 'unknown_server') { - // Not yet connected; jump straight to Actions - // so the user can finish OAuth. Nothing here - // can do it on their behalf. + // Not yet connected; jump straight to Actions so the user can finish OAuth. Nothing here can do it on their behalf. navigate('/actions'); } else if (id) { - // Activation succeeded; clear the banner so the user - // gets visual confirmation the click did something. + // Activation succeeded; clear the banner so the user gets visual confirmation the click did something. dispatch(clearMcpSuggestions({ sessionId: id })); } } catch (e: any) { @@ -2442,9 +2251,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ); }; -// Brief toast above the build chat's composer confirming a model switch also -// changes the model the scheduled workflow will run on. Holds the last label in -// a ref so the exit fade renders content instead of blanking mid-animation. +// Brief toast above the build chat's composer confirming a model switch also changes the model the scheduled workflow will run on. Holds the last label in a ref so the exit fade renders content instead of blanking mid-animation. function WorkflowModelNotice({ c, label }: { c: ReturnType; label: string | null }) { const last = React.useRef(null); if (label) last.current = label; diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index b2010954..de9d1833 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -44,8 +44,7 @@ interface Props { thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; onThinkingLevelChange?: (level: 'off' | 'low' | 'medium' | 'high' | 'auto') => void; onActivityLabelChange?: (label: string | null) => void; - // Seed the composer with this text (unsent), so a starter-prompt click opens - // the chat with the message already typed, ready for the user to hit send. + // Seed the composer with this text (unsent), so a starter-prompt click opens the chat with the message already typed, ready for the user to hit send. prefillPrompt?: string; // Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run..."). placeholderOverride?: string; @@ -69,9 +68,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, if (autoFocus) editorRef.current?.focus(); }, [autoFocus]); - // Drop the seeded prompt into the editor when it arrives (starter-prompt click). - // It renders translucent, reading as a pending suggestion, and solidifies the - // moment the user takes it (a keypress, including Enter-to-send, or any edit). + // Drop the seeded prompt into the editor when it arrives (starter-prompt click). It renders translucent, reading as a pending suggestion, and solidifies the moment the user takes it (a keypress, including Enter-to-send, or any edit). const prefilledRef = useRef(null); useEffect(() => { if (!prefillPrompt || prefilledRef.current === prefillPrompt) return; @@ -83,8 +80,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, ta.setSelectionRange(prefillPrompt.length, prefillPrompt.length); } else { editor.textContent = prefillPrompt; - // Park the caret AFTER the seeded text, not at position 0 (the default for a - // freshly-set textContent), so the user types/sends from the end. + // Park the caret AFTER the seeded text, not at position 0 (the default for a freshly-set textContent), so the user types/sends from the end. const sel = window.getSelection(); if (sel) { const range = document.createRange(); @@ -187,9 +183,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, const editor = editorRef.current; if (!editor || disabled) return; if (summarizingPath || summarizingAll) return; - // If files are flagged too big, popup will appear above the input. Capture - // the user's intent to send so once they pick Shrink all / Remove all and - // the queue drains, the send fires automatically (zero extra clicks). + // If files are flagged too big, popup will appear above the input. Capture the user's intent to send so once they pick Shrink all / Remove all and the queue drains, the send fires automatically (zero extra clicks). if (oversizeQueue.length > 0) { pendingSendRef.current = () => { handleSend(); }; return; @@ -208,16 +202,12 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, }); if (block) { if (block.kind === 'too_long') { - // This one message is too big to send even with zero history, so - // compaction can't save it. Hard-block and tell the user plainly; they - // shorten it and the block clears on the next send attempt. Don't fire - // /compact (pointless) and don't queue a retry (it'd just re-block). + // This one message is too big to send even with zero history, so compaction can't save it. Hard-block and tell the user plainly; they shorten it and the block clears on the next send attempt. Don't fire /compact (pointless) and don't queue a retry (it'd just re-block). pendingSendRef.current = null; setSendBlock(block); return; } - // kind === 'compacting': history is the overflow source, which we CAN - // shrink. Auto-compact invisibly, then continue this same send. + // kind === 'compacting': history is the overflow source, which we CAN shrink. Auto-compact invisibly, then continue this same send. if (!sessionId || compactionInFlightRef.current) return; compactionInFlightRef.current = true; setSendBlock(null); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/helpers.ts b/frontend/src/app/pages/AgentChat/ChatInput/helpers.ts index 04bdc243..3a84ffb0 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/helpers.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/helpers.ts @@ -4,9 +4,7 @@ export function formatTokenCount(n: number): string { return String(n); } -// Path basename that works on both POSIX (/Users/x/file.pdf) and Windows -// (C:\Users\x\file.pdf). Splits on either separator; falls back to the -// raw path so empty segments don't yield ''. +// Path basename that works on both POSIX (/Users/x/file.pdf) and Windows (C:\Users\x\file.pdf). Splits on either separator; falls back to the raw path so empty segments don't yield ''. export function basename(p: string): string { if (!p) return ''; const parts = p.split(/[\\/]/).filter(Boolean); diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useChatInputModel.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useChatInputModel.ts index 807ba670..f08b3afd 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useChatInputModel.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useChatInputModel.ts @@ -51,11 +51,7 @@ export function useChatInputModel(model: string) { return ((m?.api as string) || 'anthropic').toLowerCase(); }, [allModelOptions.flat, model]); - // Mirrors backend agent_manager._resolve_attachments support matrix. - // PDFs: Anthropic, Gemini, OpenRouter (file-parser plugin), and - // OpenAI direct on GPT-5.x non-Codex (anthropic_proxy bypasses - // 9router and POSTs to api.openai.com via anthropic_to_openai.py). - // Images: every provider via 9router image_url translation. + // Mirrors backend agent_manager._resolve_attachments support matrix. PDFs: Anthropic, Gemini, OpenRouter (file-parser plugin), and OpenAI direct on GPT-5.x non-Codex (anthropic_proxy bypasses 9router and POSTs to api.openai.com via anthropic_to_openai.py). Images: every provider via 9router image_url translation. const isCodexModel = typeof model === 'string' && (model.toLowerCase().includes('codex') || model.toLowerCase().startsWith('cx/')); const pdfSupported = ( ['anthropic', 'gemini', 'gemini-cli', 'openrouter'].includes(currentModelApi) || diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts index dfc2dd90..93687e0b 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts @@ -4,21 +4,13 @@ import { API_BASE, getAuthToken } from '@/shared/config'; import { ForcedToolGroup } from '../types'; import { basename } from '../helpers'; -// Only auto-shrink a file when it literally won't fit (98%+ of the window on its -// own). Below that, send it NATIVELY (base64 document block) the way claude.ai / -// OpenAI / Gemini do — the model reads the PDF server-side, instantly, no separate -// summarize round-trip. The old 50% trigger was force-summarizing files that fit -// fine, which is the entire reason our file flow felt 60s-slow vs their instant: we -// were doing pre-processing work the big providers simply don't do. 98% (not 100%) -// leaves a sliver for the prompt itself so a barely-fitting file doesn't 4xx; if the -// conversation later grows past the window, auto-compact handles it. +// Only auto-shrink a file when it literally won't fit (98%+ of the window on its own). Below that, send it NATIVELY (base64 document block) the way claude.ai / OpenAI / Gemini do — the model reads the PDF server-side, instantly, no separate summarize round-trip. The old 50% trigger was force-summarizing files that fit fine, which is the entire reason our file flow felt 60s-slow vs their instant: we were doing pre-processing work the big providers simply don't do. 98% (not 100%) leaves a sliver for the prompt itself so a barely-fitting file doesn't 4xx; if the conversation later grows past the window, auto-compact handles it. function shrinkThreshold(modelCtx: number): number { return Math.floor(modelCtx * 0.98); } export type SendBlock = null | { - // 'compacting' = history overflow, auto-compact can fix it. - // 'too_long' = this single message exceeds the window on its own; hard block. + // 'compacting' = history overflow, auto-compact can fix it. 'too_long' = this single message exceeds the window on its own; hard block. kind: 'compacting' | 'too_long'; estimate: number; window: number; @@ -45,8 +37,7 @@ export function useContextFiles( const [summarizingAll, setSummarizingAll] = useState(false); const [summarizeError, setSummarizeError] = useState(null); const [sendBlock, setSendBlock] = useState(null); - // Set when user clicked Send but oversize popup intercepted. Once the queue drains, - // we trigger the send automatically so the user doesn't have to click Send a second time. + // Set when user clicked Send but oversize popup intercepted. Once the queue drains, we trigger the send automatically so the user doesn't have to click Send a second time. const pendingSendRef = useRef<(() => void) | null>(null); const uploadAndAttachFiles = useCallback(async (files: File[]) => { @@ -96,9 +87,7 @@ export function useContextFiles( setOversizeQueue((q) => q.filter((o) => o.path !== path)); }, []); - // Single-click batch: remove EVERY oversize file. The auto-retry effect below - // notices the queue went empty and fires the pending send (if any), so going - // from 5 too-big files to a sent message is 1 click instead of 6. + // Single-click batch: remove EVERY oversize file. The auto-retry effect below notices the queue went empty and fires the pending send (if any), so going from 5 too-big files to a sent message is 1 click instead of 6. const detachAllOversize = useCallback(() => { setOversizeQueue((q) => { const paths = new Set(q.map((o) => o.path)); @@ -130,9 +119,7 @@ export function useContextFiles( setContextPaths((prev) => prev.map((cp) => cp.path === path ? { ...cp, path: newPath, tokens: newTokens, kind: 'text', media_type: 'text/plain' } : cp)); setOversizeQueue((q) => q.filter((o) => o.path !== path)); } catch (err) { - // Don't show backend stack traces / model error JSON to users. The raw error - // ("Error code: 400 - {'error': {'message': '[claude/...] prompt is too long...'}}") - // is logged in console for devs; users see a plain English ask. + // Don't show backend stack traces / model error JSON to users. The raw error ("Error code: 400 - {'error': {'message': '[claude/...] prompt is too long...'}}") is logged in console for devs; users see a plain English ask. if (err instanceof Error) console.error('[summarize] failed:', err.message); setSummarizeError('Could not shrink the file. Try removing it, or pick a model with a bigger window in Settings.'); } finally { @@ -140,10 +127,7 @@ export function useContextFiles( } }, [currentModelCtx, model, summarizingPath]); - // Single-click batch: shrink EVERY oversize file in parallel. Server-side each - // call already chunks-and-merges via asyncio.gather, so N files at once is bounded - // by the slowest one's chunk count, not N x single-file time. Errors from any - // one file land in summarizeError; others continue. + // Single-click batch: shrink EVERY oversize file in parallel. Server-side each call already chunks-and-merges via asyncio.gather, so N files at once is bounded by the slowest one's chunk count, not N x single-file time. Errors from any one file land in summarizeError; others continue. const summarizeAllOversize = useCallback(async () => { if (summarizingAll) return; const snapshot = oversizeQueue.slice(); @@ -188,12 +172,7 @@ export function useContextFiles( } }, [oversizeQueue, summarizingAll, currentModelCtx, model]); - // Auto-shrink: as soon as a file lands oversize, fire the shrink. No "this file is - // too big, what do you want to do?" prompt — there's no real choice, we KNOW the only - // reasonable answer is "shrink it". The popup becomes a status indicator ("Shrinking - // X") not a question, and disappears the moment shrinking finishes. If the user wanted - // the original unshrunk file they'd not have attached something bigger than the model's - // window in the first place; we still expose detach-on-chip if they change their mind. + // Auto-shrink: as soon as a file lands oversize, fire the shrink. No "this file is too big, what do you want to do?" prompt — there's no real choice, we KNOW the only reasonable answer is "shrink it". The popup becomes a status indicator ("Shrinking X") not a question, and disappears the moment shrinking finishes. If the user wanted the original unshrunk file they'd not have attached something bigger than the model's window in the first place; we still expose detach-on-chip if they change their mind. const lastAutoShrinkSig = useRef(''); useEffect(() => { if (oversizeQueue.length === 0) return; @@ -204,8 +183,7 @@ export function useContextFiles( summarizeAllOversize(); }, [oversizeQueue, summarizingAll, summarizingPath, summarizeAllOversize]); - // Auto-retry: when the queue drains AND the user had a pending send, fire it. - // Zero extra clicks; user types "hi" with attached files, the shrink happens, send fires. + // Auto-retry: when the queue drains AND the user had a pending send, fire it. Zero extra clicks; user types "hi" with attached files, the shrink happens, send fires. useEffect(() => { if (oversizeQueue.length === 0 && !summarizingAll && !summarizingPath && pendingSendRef.current) { const send = pendingSendRef.current; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts b/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts index abe147da..340894b6 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/sendHelpers.ts @@ -14,14 +14,7 @@ interface SendBlockInputs { sessionFrameworkOverhead: number; } -// Pre-send dry-run guard. Sums every known component of next-turn input -// (history estimate from props, system prompt, framework/MCP overhead -// last reported by the API, attached file token estimates, and the -// prompt itself). If the sum exceeds 95% of the model's window, returns a -// block with concrete recovery actions instead of round-tripping to a -// doomed API call. Conservative on purpose: tokenizers differ across -// providers (char/4 is rough), so we leave 5% headroom plus the API's -// own response budget. +// Pre-send dry-run guard. Sums every known component of next-turn input (history estimate from props, system prompt, framework/MCP overhead last reported by the API, attached file token estimates, and the prompt itself). If the sum exceeds 95% of the model's window, returns a block with concrete recovery actions instead of round-tripping to a doomed API call. Conservative on purpose: tokenizers differ across providers (char/4 is rough), so we leave 5% headroom plus the API's own response budget. export function computeSendBlock({ trimmed, currentModelCtx, historyUsed, contextPaths, sessionFrameworkOverhead }: SendBlockInputs): NonNullable | null { const win = currentModelCtx; const history = Math.max(0, historyUsed); @@ -35,11 +28,7 @@ export function computeSendBlock({ trimmed, currentModelCtx, historyUsed, contex for (const cp of contextPaths) { if ((cp.tokens || 0) > (largest?.tokens || 0)) largest = { path: cp.path, tokens: cp.tokens || 0 }; } - // Distinguish "history is the culprit" (compaction can fix it) from "this one - // message is too big on its own" (compaction can't help: dropping all prior - // turns still leaves framework+files+prompt over the window). The latter only - // happens with a giant pasted prompt, since attached files auto-shrink on - // attach. We surface that as a hard block instead of a doomed compact loop. + // Distinguish "history is the culprit" (compaction can fix it) from "this one message is too big on its own" (compaction can't help: dropping all prior turns still leaves framework+files+prompt over the window). The latter only happens with a giant pasted prompt, since attached files auto-shrink on attach. We surface that as a hard block instead of a doomed compact loop. const nonHistory = framework + filesSum + promptTokens + systemTokens; const kind: 'compacting' | 'too_long' = nonHistory > Math.floor(win * 0.95) ? 'too_long' : 'compacting'; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx index 5b6774b8..b4460e3a 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ChatInputToolbar.tsx @@ -81,9 +81,7 @@ export const ChatInputToolbar: React.FC = (p) => { }, }; - // On the free trial the run is fixed (model + thinking forced server-side), so hide both - // the model picker and the thinking selector, there's nothing to choose. Returns the moment - // a real model is connected. + // On the free trial the run is fixed (model + thinking forced server-side), so hide both the model picker and the thinking selector, there's nothing to choose. Returns the moment a real model is connected. const hideForTrial = useAppSelector((s) => hasFreeTrialActive(s) && !hasModelConnected(s)); return ( diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ContextRing.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ContextRing.tsx index 1274b78e..afa4cb75 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ContextRing.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ContextRing.tsx @@ -4,9 +4,7 @@ import Tooltip from '@mui/material/Tooltip'; import { formatTokenCount } from '../helpers'; export const ContextRing: React.FC<{ used: number; limit: number; accentColor: string; trackColor: string }> = ({ used, limit, accentColor, trackColor }) => { - // Track the previous fill so a DROP (compaction freed space) can play a brief - // "settle" cue: the ring eases down AND flashes once toward the track color, - // signaling "we just made room" without a loud banner. A rise just eases up. + // Track the previous fill so a DROP (compaction freed space) can play a brief "settle" cue: the ring eases down AND flashes once toward the track color, signaling "we just made room" without a loud banner. A rise just eases up. const prevUsed = React.useRef(used); const [justCompacted, setJustCompacted] = React.useState(false); React.useEffect(() => { @@ -40,8 +38,7 @@ export const ContextRing: React.FC<{ used: number; limit: number; accentColor: s strokeLinecap="round" transform={`rotate(-90 ${size / 2} ${size / 2})`} style={{ - // 600ms cubic-bezier ease on the fill: a rise glides up, a compaction - // glides down. The one-shot opacity dip is the "settle" flash on drop. + // 600ms cubic-bezier ease on the fill: a rise glides up, a compaction glides down. The one-shot opacity dip is the "settle" flash on drop. transition: 'stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease', opacity: justCompacted ? 0.35 : 1, }} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ModeControl.tsx b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ModeControl.tsx index ec452af8..0f4e8adb 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ModeControl.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/toolbar/ModeControl.tsx @@ -30,9 +30,7 @@ interface Props { export const ModeControl: React.FC = ({ c, menuPaperProps, modeConf, modesArr, mode, onModeChange, iconMap, modeAnchor, setModeAnchor, setModelAnchor, allModelFlat, model, }) => { - // On the free trial the model is fixed server-side, so there's nothing to pick: hide the - // model control. The moment a real model is connected we show it again, even if trial state - // lingers (gate on !hasModelConnected, not just the trial flag). + // On the free trial the model is fixed server-side, so there's nothing to pick: hide the model control. The moment a real model is connected we show it again, even if trial state lingers (gate on !hasModelConnected, not just the trial flag). const hideModelPicker = useAppSelector((s) => hasFreeTrialActive(s) && !hasModelConnected(s)); return ( <> diff --git a/frontend/src/app/pages/AgentChat/ForceStopAgentBar.tsx b/frontend/src/app/pages/AgentChat/ForceStopAgentBar.tsx index c78ee544..6ce78863 100644 --- a/frontend/src/app/pages/AgentChat/ForceStopAgentBar.tsx +++ b/frontend/src/app/pages/AgentChat/ForceStopAgentBar.tsx @@ -1,8 +1,4 @@ -// Footer for an agent card that's a workflow sidecar (Test Agent, or a -// watched run). It replaces the normal composer: while the agent runs you -// can't meaningfully chat, but you often want to kill it. Once a Test Agent -// finishes, the red "Force Stop" becomes the decision point: keep editing the -// workflow, or save the edits (which commits the draft and closes this card). +// Footer for an agent card that's a workflow sidecar (Test Agent, or a watched run). It replaces the normal composer: while the agent runs you can't meaningfully chat, but you often want to kill it. Once a Test Agent finishes, the red "Force Stop" becomes the decision point: keep editing the workflow, or save the edits (which commits the draft and closes this card). import React from 'react'; import Box from '@mui/material/Box'; @@ -15,8 +11,7 @@ interface Props { onStop: () => void; onSaveWorkflow: () => void; onContinueEditing: () => void; - // 'running' while a test drives the steps; 'complete'/'error' when done. - // null for a watched (non-test) run, which only ever offers Force Stop. + // 'running' while a test drives the steps; 'complete'/'error' when done. null for a watched (non-test) run, which only ever offers Force Stop. testState?: 'running' | 'complete' | 'error' | null; } diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index a4efed3a..0840a7e3 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -6,10 +6,7 @@ import { ArrowLeft } from 'lucide-react'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { STARTER_CATEGORIES } from '@/shared/starterCategories'; -// Quick-reply chips that sit UNDER the streamed greeting bubble. Two levels: category -> -// concrete prompts. Research/Write/Learn -> onPick (real run); Build -> onPickBuilder (prefill). -// The greeting itself is a real streamed assistant message (see useWelcomeGreeting); this is just -// the follow-up affordance. Pure UI, no run until the parent fires. +// Quick-reply chips that sit UNDER the streamed greeting bubble. Two levels: category -> concrete prompts. Research/Write/Learn -> onPick (real run); Build -> onPickBuilder (prefill). The greeting itself is a real streamed assistant message (see useWelcomeGreeting); this is just the follow-up affordance. Pure UI, no run until the parent fires. const WelcomeQuickReplies: React.FC<{ c: ClaudeTokens; onPick: (prompt: string) => void; diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index a62a8047..2110964e 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -68,10 +68,7 @@ const StreamingCursor: React.FC = () => { const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n'; -// Remembered full-render content height per oversized message id. Module-scoped so -// it survives the transcript window unmounting/remounting the bubble: when a big -// message goes off-screen we reserve the exact height it had when rendered, so its -// box doesn't collapse and the scrollbar doesn't jump as it crosses the viewport. +// Remembered full-render content height per oversized message id. Module-scoped so it survives the transcript window unmounting/remounting the bubble: when a big message goes off-screen we reserve the exact height it had when rendered, so its box doesn't collapse and the scrollbar doesn't jump as it crosses the viewport. const oversizedContentHeights = new Map(); interface OpenSwarmErrorInfo { @@ -124,8 +121,7 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro : 'Wait for the reset window shown by your provider, or switch to another model.', }; } - // Transient throttle: Anthropic's upstream overload or our own pool-shed. Not the user's - // fault and not a plan cap, so don't say "upgrade", just tell them it's busy. claude.ai-style. + // Transient throttle: Anthropic's upstream overload or our own pool-shed. Not the user's fault and not a plan cap, so don't say "upgrade", just tell them it's busy. claude.ai-style. if (/rate_limit_error|free_pool_busy|overloaded_error|too many requests/i.test(text)) { return { kind: 'network', @@ -134,8 +130,7 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro }; } if (/free_trial_exhausted|used your free|free OpenSwarm runs/i.test(text)) { - // Once a real model is connected, the prompt isn't lost: offer a one-tap pick-up-where-you-left-off - // that resends the last ask on the new model. Before connecting, the CTA still routes to Settings. + // Once a real model is connected, the prompt isn't lost: offer a one-tap pick-up-where-you-left-off that resends the last ask on the new model. Before connecting, the CTA still routes to Settings. if (ctx?.hasModel) { return { kind: 'cap', @@ -192,9 +187,7 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro } let lead: string; if (win && input) { - // input is the API-reported total which includes our preset, tool - // defs, MCP descriptions etc. Subtract those for the user-facing - // "your content" number so we don't blame the user for our overhead. + // input is the API-reported total which includes our preset, tool defs, MCP descriptions etc. Subtract those for the user-facing "your content" number so we don't blame the user for our overhead. const userContent = Math.max(0, input - fw); lead = `The request totalled ~${formatTokens(input)} of ${formatTokens(win)} tokens this model can hold (your messages + files: ~${formatTokens(userContent)}).`; } else if (win) { @@ -235,8 +228,7 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro detail: "We couldn't reach the service. Once your connection is back, send a new message to continue.", }; } - // Last resort: a raw API error or SDK traceback we don't have specific copy for. Never let JSON - // or a stack trace land in the card; give a calm retry instead (the raw text is in the console). + // Last resort: a raw API error or SDK traceback we don't have specific copy for. Never let JSON or a stack trace land in the card; give a calm retry instead (the raw text is in the console). if (/API Error:|invalid_request_error|"type"\s*:\s*"error"|Command failed with exit code/i.test(text)) { return { kind: 'network', @@ -905,9 +897,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o const rawSysText = typeof content === 'string' ? content : JSON.stringify(content); const { body: sysBody, note: sysNote } = extractPlatformNote(rawSysText); const sysText = sysNote || sysBody; - // A raw subprocess/API failure ("Command failed with exit code 1", API Error JSON) is dev - // jargon, and the same failure is already shown as a friendly card on the assistant side. - // Swallow just that stderr dump so the user sees one calm card, not jargon beneath it. + // A raw subprocess/API failure ("Command failed with exit code 1", API Error JSON) is dev jargon, and the same failure is already shown as a friendly card on the assistant side. Swallow just that stderr dump so the user sees one calm card, not jargon beneath it. if (/Command failed with exit code|API Error:|invalid_request_error|"type"\s*:\s*"error"|Check stderr output/i.test(sysText)) { return null; } @@ -946,8 +936,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o const { userMessage: displayText, elements: selectedElements } = isUser ? parseElementContext(rawText) : { userMessage: rawText, elements: [] }; - // A message longer than ~2 screens of text gets the placeholder + block - // virtualization treatment (full render in view, reserved-height placeholder off). + // A message longer than ~2 screens of text gets the placeholder + block virtualization treatment (full render in view, reserved-height placeholder off). const isOversizedAssistant = !isUser && !isStreaming && rawText.length > oversizedCharThreshold(viewportHeight, viewportWidth); const [isOversizedInViewport, setIsOversizedInViewport] = useState(false); @@ -970,11 +959,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o >{markdownWindow.text} ), [markdownWindow.text]); - // Height to reserve for this message's off-screen placeholder before it has ever - // been measured. Estimated from the FULL text length (we render in full when in - // view) with the same model as AgentChat's spacer estimate, so the placeholder - // and the spacer reserve the same space. Once rendered, oversizedContentHeights - // wins over this. + // Height to reserve for this message's off-screen placeholder before it has ever been measured. Estimated from the FULL text length (we render in full when in view) with the same model as AgentChat's spacer estimate, so the placeholder and the spacer reserve the same space. Once rendered, oversizedContentHeights wins over this. const placeholderFallbackHeight = useMemo( () => estimateRenderedTextHeight(rawText, viewportWidth), [rawText, viewportWidth], @@ -998,9 +983,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o const activeSessionId = useAppSelector((state) => state.agents.activeSessionId); const openswarmError = !isUser ? parseOpenSwarmError(rawText, overflowCtx) : null; - // Reports asynchronously, bc without this an oversized message that mounts in - // view (e.g. scrolling up into the agent's reply) would paint the blank - // placeholder box for a frame and then pop in the real markdown. + // Reports asynchronously, bc without this an oversized message that mounts in view (e.g. scrolling up into the agent's reply) would paint the blank placeholder box for a frame and then pop in the real markdown. React.useLayoutEffect(() => { if (!isOversizedAssistant) { setIsOversizedInViewport(false); @@ -1010,13 +993,10 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o const node = bubbleRootRef.current; if (!node) return; - // One-screen rootMargin so the message renders its full markdown for a screen - // above and below the visible area; only once it drifts a screen past the - // viewport does it drop to the height-reserved placeholder. + // One-screen rootMargin so the message renders its full markdown for a screen above and below the visible area; only once it drifts a screen past the viewport does it drop to the height-reserved placeholder. const bufferPx = Math.max(180, Math.round(viewportHeight || 240)); - // Resolve visibility synchronously (on mount and on demand) so the correct - // content paints without waiting on the observer's async callback. + // Resolve visibility synchronously (on mount and on demand) so the correct content paints without waiting on the observer's async callback. const rootEl: Element = (scrollRoot as Element) ?? document.scrollingElement ?? document.documentElement; const evaluate = () => { const rootRect = rootEl.getBoundingClientRect(); @@ -1035,9 +1015,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o }); observer.observe(node); - // A programmatic jump (scroll-to-bottom / open pin) settles after this mounts; - // re-evaluate synchronously when it does, since the observer sometimes misses - // the final transition and leaves this stuck as a placeholder. + // A programmatic jump (scroll-to-bottom / open pin) settles after this mounts; re-evaluate synchronously when it does, since the observer sometimes misses the final transition and leaves this stuck as a placeholder. scrollRoot?.addEventListener(RECHECK_VISIBILITY_EVENT, evaluate); return () => { observer.disconnect(); @@ -1045,10 +1023,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o }; }, [isOversizedAssistant, message.id, scrollRoot, viewportHeight]); - // Remember the full-render height of an oversized message while it is on-screen, - // so its off-screen placeholder can reserve exactly that height (see the - // module-level oversizedContentHeights cache). Measured on the content box only, - // which excludes the action bar (rendered by the parent) to avoid a feedback loop. + // Remember the full-render height of an oversized message while it is on-screen, so its off-screen placeholder can reserve exactly that height (see the module-level oversizedContentHeights cache). Measured on the content box only, which excludes the action bar (rendered by the parent) to avoid a feedback loop. React.useLayoutEffect(() => { if (!isOversizedAssistant || !shouldRenderMarkdown) return; const node = contentRef.current; @@ -1064,9 +1039,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o } }, [message.id, openswarmError?.kind]); - // A run that failed on a subscription/connection error means the card may be - // showing a stale "Connected" (the optimistic mark, or a token that went stale - // mid-session); re-pull the real 9Router/cloud status so it flips to Reconnect. + // A run that failed on a subscription/connection error means the card may be showing a stale "Connected" (the optimistic mark, or a token that went stale mid-session); re-pull the real 9Router/cloud status so it flips to Reconnect. React.useEffect(() => { if (openswarmError?.kind === 'auth') { dispatch(fetchSubscriptionStatus()); @@ -1117,10 +1090,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o sx={{ maxWidth: '85%', minWidth: 0, - // Oversized messages are block-virtualized, so the set of rendered - // blocks (and thus the widest visible content) changes as you scroll. - // Pin them to a stable width so the bubble doesn't shrink-to-fit and - // resize horizontally frame to frame. Normal messages keep shrink-to-fit. + // Oversized messages are block-virtualized, so the set of rendered blocks (and thus the widest visible content) changes as you scroll. Pin them to a stable width so the bubble doesn't shrink-to-fit and resize horizontally frame to frame. Normal messages keep shrink-to-fit. ...(isOversizedAssistant ? { width: '85%' } : {}), bgcolor: isUser ? c.user.bubble : c.bg.surface, border: isUser ? (isFailed ? `1px solid ${c.status.error}` : 'none') : `1px solid ${c.border.subtle}`, @@ -1131,10 +1101,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o overflow: 'hidden', opacity: isPending ? 0.7 : 1, transition: 'opacity 0.2s, border-color 0.2s', - // User bubbles ease in instead of popping. Assistant bubbles are left - // alone on purpose: they reveal by typing, and animating them would - // flash at the streaming -> committed handoff. Transform+opacity only, - // so it rides the compositor and never shifts layout or the scroll. + // User bubbles ease in instead of popping. Assistant bubbles are left alone on purpose: they reveal by typing, and animating them would flash at the streaming -> committed handoff. Transform+opacity only, so it rides the compositor and never shifts layout or the scroll. ...(isUser && !editing ? { animation: 'msgBubbleEnter 160ms ease-out', '@keyframes msgBubbleEnter': { @@ -1347,10 +1314,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o {isOversizedAssistant && !isOversizedInViewport ? ( = React.memo(({ message, editing = false, o aria-hidden="true" /> ) : isOversizedAssistant ? ( - // In view, but virtualize WITHIN the message: only blocks near the - // viewport render their markdown, the rest are reserved-height - // placeholders, so an extremely long message never parses/mounts - // more than the on-screen portion plus a buffer. + // In view, but virtualize WITHIN the message: only blocks near the viewport render their markdown, the rest are reserved-height placeholders, so an extremely long message never parses/mounts more than the on-screen portion plus a buffer. = React.memo(({ message, editing = false, o viewportWidth={viewportWidth} /> ) : ( - // Normal (non-oversized) render. Streaming always lands here - // (oversized requires !isStreaming); the reveal subtree lets - // useSmoothText append chars between parses. + // Normal (non-oversized) render. Streaming always lands here (oversized requires !isStreaming); the reveal subtree lets useSmoothText append chars between parses. {renderedMarkdown} )} {isStreaming && } diff --git a/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx index 5f5dd97c..3c7e1762 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx @@ -15,11 +15,7 @@ interface Props { const StreamingBubble: React.FC = ({ sessionId, activeBranchId, turnLabel, onStreamGrew }) => { const streamingMessage = useStreamingMessage(sessionId); const rawContent = streamingMessage?.content ?? ''; - // Smooth-reveal the assistant's generated text at a steady cadence so it reads - // like typing instead of bursty network chunks. Provider-agnostic by design: - // every model (Anthropic/OpenAI/Gemini/OpenRouter/custom) funnels through this - // same streaming slice, so smoothing here covers all of them at once. Tool-call - // input is left raw (it's args, not prose). Zero added TTFT (see useSmoothText). + // Smooth-reveal the assistant's generated text at a steady cadence so it reads like typing instead of bursty network chunks. Provider-agnostic by design: every model (Anthropic/OpenAI/Gemini/OpenRouter/custom) funnels through this same streaming slice, so smoothing here covers all of them at once. Tool-call input is left raw (it's args, not prose). Zero added TTFT (see useSmoothText). const isTextRole = streamingMessage?.role !== 'tool_call'; const { text: smoothContent, revealRef } = useSmoothText(rawContent, isTextRole); const typedContent = isTextRole ? smoothContent : rawContent; diff --git a/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx b/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx index 288e80d1..8266d80a 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/WindowedMarkdown.tsx @@ -4,23 +4,13 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './markdownMeasure'; -// Intra-message virtualization for very long assistant messages. The text is split -// into FIXED blocks (each block always covers the same character range), so unlike -// the old growing-tail chunking nothing shifts as you scroll, and no scroll -// correction is needed. Only blocks within a screen of the viewport actually render -// their markdown; the rest are height-reserved placeholders, so an extremely long -// message never parses or mounts more than the on-screen portion plus a buffer. +// Intra-message virtualization for very long assistant messages. The text is split into FIXED blocks (each block always covers the same character range), so unlike the old growing-tail chunking nothing shifts as you scroll, and no scroll correction is needed. Only blocks within a screen of the viewport actually render their markdown; the rest are height-reserved placeholders, so an extremely long message never parses or mounts more than the on-screen portion plus a buffer. const BLOCK_TARGET_CHARS = 4_000; -// Remembered measured height per block (`${messageId}#${index}`). Module-scoped so -// it survives the block unmounting/remounting as you scroll, keeping the reserved -// placeholder heights (and thus scroll position) stable. +// Remembered measured height per block (`${messageId}#${index}`). Module-scoped so it survives the block unmounting/remounting as you scroll, keeping the reserved placeholder heights (and thus scroll position) stable. const blockHeights = new Map(); -// Split markdown at blank lines that sit OUTSIDE fenced code blocks, so each block -// is a self-contained markdown fragment we can parse on its own. A fence (``` or -// ~~~) toggles "inside code" so we never cut a code block in half. Blocks grow to -// ~targetChars then break at the next safe boundary. +// Split markdown at blank lines that sit OUTSIDE fenced code blocks, so each block is a self-contained markdown fragment we can parse on its own. A fence (``` or ~~~) toggles "inside code" so we never cut a code block in half. Blocks grow to ~targetChars then break at the next safe boundary. function splitMarkdownIntoBlocks(text: string, targetChars: number): string[] { if (text.length <= targetChars) return [text]; const lines = text.split('\n'); @@ -67,8 +57,7 @@ const MarkdownBlock: React.FC<{ const node = ref.current; if (!node) return; const bufferPx = Math.max(180, Math.round(viewportHeight || 240)); - // Resolve visibility synchronously (on mount and on demand) so an on-screen - // block paints its markdown without waiting on the observer's async callback. + // Resolve visibility synchronously (on mount and on demand) so an on-screen block paints its markdown without waiting on the observer's async callback. const rootEl: Element = (scrollRoot as Element) ?? document.scrollingElement ?? document.documentElement; const evaluate = () => { const rootRect = rootEl.getBoundingClientRect(); diff --git a/frontend/src/app/pages/AgentChat/bubbles/markdownMeasure.ts b/frontend/src/app/pages/AgentChat/bubbles/markdownMeasure.ts index 1839f32e..ad1e57a9 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/markdownMeasure.ts +++ b/frontend/src/app/pages/AgentChat/bubbles/markdownMeasure.ts @@ -1,14 +1,6 @@ -// Shared text-measurement heuristics for chat bubbles. These are empirical pixel -// values measured against the bubble styling in MessageBubble; they're used to -// estimate how tall a chunk of text will render (for spacer / placeholder height -// reservation) and how many characters make a message "oversized" enough to -// virtualize. They're estimates only: actual heights are measured once an element -// is on screen and override these everywhere. +// Shared text-measurement heuristics for chat bubbles. These are empirical pixel values measured against the bubble styling in MessageBubble; they're used to estimate how tall a chunk of text will render (for spacer / placeholder height reservation) and how many characters make a message "oversized" enough to virtualize. They're estimates only: actual heights are measured once an element is on screen and override these everywhere. -// Fired on the scroll container after a programmatic jump (scroll-to-bottom / -// initial open pin) settles, so oversized messages and their blocks re-evaluate -// visibility synchronously instead of waiting on the async IntersectionObserver, -// which occasionally misses the final transition and leaves a stuck placeholder. +// Fired on the scroll container after a programmatic jump (scroll-to-bottom / initial open pin) settles, so oversized messages and their blocks re-evaluate visibility synchronously instead of waiting on the async IntersectionObserver, which occasionally misses the final transition and leaves a stuck placeholder. export const RECHECK_VISIBILITY_EVENT = 'chat-recheck-visibility'; export const BUBBLE_LINE_HEIGHT_PX = 22; // line-height of bubble body text @@ -17,11 +9,7 @@ export const BUBBLE_WIDTH_RATIO = 0.85; // bubbles are maxWidth: 85% of th export const MIN_CHARS_PER_VIEWPORT = 2_000; // floor so tiny/zero viewports still allow real-sized messages export const OVERSIZED_VIEWPORT_MULTIPLE = 2; // a message taller than ~2 screens gets virtualized -// Tweak on the line-based estimate. Kept at 1.0 so estimates lean accurate/under -// rather than over: the scroll-height lock tolerates under-estimates fine (the -// total just grows monotonically as things measure), but OVER-estimates inflate -// the lock's compensating pad into visible empty space below the chat. Prose in -// particular over-estimated badly at higher values. +// Tweak on the line-based estimate. Kept at 1.0 so estimates lean accurate/under rather than over: the scroll-height lock tolerates under-estimates fine (the total just grows monotonically as things measure), but OVER-estimates inflate the lock's compensating pad into visible empty space below the chat. Prose in particular over-estimated badly at higher values. const MARKDOWN_DENSITY_FACTOR = 1.0; // Characters that fit on one rendered line at this width. @@ -30,11 +18,7 @@ function charsPerLine(viewportWidth: number): number { return Math.max(36, Math.floor(readableWidth / BUBBLE_AVG_CHAR_WIDTH_PX)); } -// Rough pixel height `text` will occupy when rendered in a bubble at this width. -// Counts by SOURCE line (each \n-delimited line takes at least one rendered line, -// plus wraps for long lines) rather than total chars, so dense markdown with many -// short lines isn't wildly under-counted. `chromePx` is the vertical padding/ -// margins around the text. +// Rough pixel height `text` will occupy when rendered in a bubble at this width. Counts by SOURCE line (each \n-delimited line takes at least one rendered line, plus wraps for long lines) rather than total chars, so dense markdown with many short lines isn't wildly under-counted. `chromePx` is the vertical padding/ margins around the text. export function estimateRenderedTextHeight(text: string, viewportWidth: number, chromePx = 40): number { const cpl = charsPerLine(viewportWidth); const sourceLines = text ? text.split('\n') : ['']; @@ -45,9 +29,7 @@ export function estimateRenderedTextHeight(text: string, viewportWidth: number, return Math.ceil(Math.max(1, lines) * BUBBLE_LINE_HEIGHT_PX * MARKDOWN_DENSITY_FACTOR) + chromePx; } -// Character count above which an assistant message is treated as "oversized" and -// gets the placeholder + block-virtualization treatment: roughly two screens of -// text, with a floor so it never trips on short messages. +// Character count above which an assistant message is treated as "oversized" and gets the placeholder + block-virtualization treatment: roughly two screens of text, with a floor so it never trips on short messages. export function oversizedCharThreshold(viewportHeight: number, viewportWidth: number): number { const visibleLines = Math.max(1, Math.ceil(Math.max(0, viewportHeight) / BUBBLE_LINE_HEIGHT_PX)); const charsPerViewport = Math.max(MIN_CHARS_PER_VIEWPORT, visibleLines * charsPerLine(viewportWidth)); diff --git a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts index 00970eb6..dddd420b 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts +++ b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts @@ -40,16 +40,14 @@ export function useSmoothText( const targetRef = useRef(target); targetRef.current = target; - // Controller state lives in refs so the rAF loop reads the latest without the - // effect re-subscribing every character. + // Controller state lives in refs so the rAF loop reads the latest without the effect re-subscribing every character. const posRef = useRef(enabled ? 0 : target.length); // float reveal position const cpsRef = useRef(0); // current reveal speed const lastRef = useRef(0); // last frame timestamp const committedRef = useRef(committedLen); const lastCommitAtRef = useRef(0); - // Imperative-tail bookkeeping: which committedLen the DOM reflects, and the - // text node + its committed baseline that per-frame appends write into. + // Imperative-tail bookkeeping: which committedLen the DOM reflects, and the text node + its committed baseline that per-frame appends write into. const domLenRef = useRef(committedLen); const nodeRef = useRef(null); const baseRef = useRef(''); @@ -64,9 +62,7 @@ export function useSmoothText( return last; }; - // After each committed render, re-anchor the tail on the fresh DOM and - // re-apply any chars the reveal position is already past, so a commit never - // rewinds visible text. + // After each committed render, re-anchor the tail on the fresh DOM and re-apply any chars the reveal position is already past, so a commit never rewinds visible text. useLayoutEffect(() => { if (!enabled) return; const node = findLastTextNode(); @@ -80,9 +76,7 @@ export function useSmoothText( // eslint-disable-next-line react-hooks/exhaustive-deps }, [committedLen, enabled]); - // ONE persistent loop, keyed only on `enabled`. It must NOT restart per token: - // an effect that depends on target.length tears the rAF down and rebuilds it on - // every delta, and that churn is what stalls the reveal. + // ONE persistent loop, keyed only on `enabled`. It must NOT restart per token: an effect that depends on target.length tears the rAF down and rebuilds it on every delta, and that churn is what stalls the reveal. useEffect(() => { if (!enabled) { posRef.current = targetRef.current.length; @@ -134,8 +128,7 @@ export function useSmoothText( }; }, [enabled]); - // Target shrank (new turn / reset / branch switch): re-sync so we don't slice - // past the end of a shorter string and so a fresh turn starts from zero. + // Target shrank (new turn / reset / branch switch): re-sync so we don't slice past the end of a shorter string and so a fresh turn starts from zero. useEffect(() => { if (posRef.current > target.length) { posRef.current = enabled ? 0 : target.length; diff --git a/frontend/src/app/pages/AgentChat/parsing/settingsToolMeta.ts b/frontend/src/app/pages/AgentChat/parsing/settingsToolMeta.ts index 57a2a9d0..5cfdbca5 100644 --- a/frontend/src/app/pages/AgentChat/parsing/settingsToolMeta.ts +++ b/frontend/src/app/pages/AgentChat/parsing/settingsToolMeta.ts @@ -1,12 +1,7 @@ -// Render an agent's SettingsWrite/SettingsRead so the transcript shows WHAT it -// touched at a glance, with secrets masked. The agent's raw `changes` input can -// carry a key it's trying to set, and the generic MCP renderer would paint it; a -// settings value is the one new place a secret could land on screen, so it's the -// one place we mask. Mirrors the backend's name rule (redaction.is_secret_field). +// Render an agent's SettingsWrite/SettingsRead so the transcript shows WHAT it touched at a glance, with secrets masked. The agent's raw `changes` input can carry a key it's trying to set, and the generic MCP renderer would paint it; a settings value is the one new place a secret could land on screen, so it's the one place we mask. Mirrors the backend's name rule (redaction.is_secret_field). const SECRET_NAME_RE = /_(key|token|secret)$/i; -// Narrow, prefix-anchored so it can't mask a long path or system prompt (those -// have slashes/spaces); it only catches a real key pasted into a non-secret field. +// Narrow, prefix-anchored so it can't mask a long path or system prompt (those have slashes/spaces); it only catches a real key pasted into a non-secret field. const KEYISH_VALUE_RE = /^(sk-|sk-ant-|AIza|ghp_|gho_|github_pat_|xox[baprs]-)/; export function isSettingsWriteTool(toolName: string): boolean { diff --git a/frontend/src/app/pages/AgentChat/parsing/toolLabels.ts b/frontend/src/app/pages/AgentChat/parsing/toolLabels.ts index 1eff4544..720dfd60 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolLabels.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolLabels.ts @@ -456,9 +456,7 @@ function _labelForMcpTool(toolName: string, seed?: string): ToolLabel | null { } const human = _humanizeName(actionRaw.replace(/^_+|_+$/g, '')); - // Internal openswarm-* tools read as a plain action with no brand prefix - // ("Settings read", not "settings: Settings read"); external connectors keep - // the brand so the user knows which app it touched ("Gmail: Send email"). + // Internal openswarm-* tools read as a plain action with no brand prefix ("Settings read", not "settings: Settings read"); external connectors keep the brand so the user knows which app it touched ("Gmail: Send email"). if (server.startsWith('openswarm-')) { return { present: human, past: human }; } diff --git a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts index 81ef0971..a49bf603 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts @@ -19,8 +19,7 @@ export function isBashTool(name: string) { export function getInputSummary(toolName: string, input: any): string { try { - // Settings tool first: legible change list, secrets masked. Must precede the - // generic MCP path (it is an MCP tool) or it renders raw changes JSON. + // Settings tool first: legible change list, secrets masked. Must precede the generic MCP path (it is an MCP tool) or it renders raw changes JSON. if (isSettingsWriteTool(toolName)) return settingsWriteSummary(input); if (isSettingsReadTool(toolName)) return ''; @@ -66,8 +65,7 @@ function formatMcpInputDisplay(input: any): string { export function formatInputDisplay(toolName: string, input: any): string { try { - // Masked, one-per-line change list instead of raw changes JSON (which would - // paint a secret value the agent tried to set). + // Masked, one-per-line change list instead of raw changes JSON (which would paint a secret value the agent tried to set). if (isSettingsWriteTool(toolName)) return settingsWriteDisplay(input); if (isSettingsReadTool(toolName)) return ''; diff --git a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx index 48a364e0..557d0297 100644 --- a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx +++ b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx @@ -913,8 +913,7 @@ export const BatchApprovalBar: React.FC = ({ requests, on for (const req of nonQuestions) onApprove(req.id); }; - // Persist the choice (4th arg alwaysAllow=true) so these tools stop prompting, - // the per-action "Always approve" was buried inside the collapsed group rows. + // Persist the choice (4th arg alwaysAllow=true) so these tools stop prompting, the per-action "Always approve" was buried inside the collapsed group rows. const handleAlwaysApproveAll = () => { for (const req of nonQuestions) onApprove(req.id, undefined, false, true); }; diff --git a/frontend/src/app/pages/AgentChat/shell/FeedbackDialog.tsx b/frontend/src/app/pages/AgentChat/shell/FeedbackDialog.tsx index 5c99d64d..e1226ff9 100644 --- a/frontend/src/app/pages/AgentChat/shell/FeedbackDialog.tsx +++ b/frontend/src/app/pages/AgentChat/shell/FeedbackDialog.tsx @@ -25,8 +25,7 @@ const FeedbackDialog: React.FC = ({ open, sentiment, sessionId, messageId const isUp = sentiment === 'up'; const handleSubmit = () => { - // Rides the same analytics channel as everything else (batches + offline - // spools to the cloud). Fire-and-forget, so the dialog closes instantly. + // Rides the same analytics channel as everything else (batches + offline spools to the cloud). Fire-and-forget, so the dialog closes instantly. report('feedback', sentiment, { message_id: messageId, session_id: sessionId, comment: comment.trim() }, { immediate: true }); setComment(''); onSubmitted(); diff --git a/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx index cafd06b2..ce03f5d4 100644 --- a/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx +++ b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx @@ -7,9 +7,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { clearRateLimited } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -// Muted, transient pill shown only after a real provider throttle outlasted the -// silent backoff. No card, no red, no CTA; it fades and auto-clears once the -// window should have passed. The "why" lives in the hover, not on the surface. +// Muted, transient pill shown only after a real provider throttle outlasted the silent backoff. No card, no red, no CTA; it fades and auto-clears once the window should have passed. The "why" lives in the hover, not on the surface. export const RateLimitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); diff --git a/frontend/src/app/pages/AgentChat/thinkingLabels.ts b/frontend/src/app/pages/AgentChat/thinkingLabels.ts index 493efbd1..da55f4bf 100644 --- a/frontend/src/app/pages/AgentChat/thinkingLabels.ts +++ b/frontend/src/app/pages/AgentChat/thinkingLabels.ts @@ -1,15 +1,10 @@ -// One source of truth for the agent's whimsical "busy" verbs, shared by the -// streaming pill (AgentChat) and the per-message thinking bubble (MessageBubble). -// `live` shows while the agent works; `past` shows once the step is done -// ("Marinated for 3s"). Keep them fun but never self-deprecating (no -// "hallucinating") so they read as personality, not a malfunction. +// One source of truth for the agent's whimsical "busy" verbs, shared by the streaming pill (AgentChat) and the per-message thinking bubble (MessageBubble). `live` shows while the agent works; `past` shows once the step is done ("Marinated for 3s"). Keep them fun but never self-deprecating (no "hallucinating") so they read as personality, not a malfunction. export interface ThinkingLabel { live: string; past: string; } -// Index 0 is the safe default the pill falls back to with no seed, so keep it -// the plain one. Everything after is fair game for chaos. +// Index 0 is the safe default the pill falls back to with no seed, so keep it the plain one. Everything after is fair game for chaos. export const THINKING_LABELS: ReadonlyArray = [ { live: 'Thinking', past: 'Thought' }, { live: 'Tokenmaxing', past: 'Tokenmaxed' }, diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index b1c2fe7b..f42362cf 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -57,8 +57,7 @@ export const CompactMcpBubble: React.FC = ({ })(); const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction; const inputSummary = mcpInfo.isMcp ? getMcpInputSummary(input, mcpInfo.action, mcpInfo.serverSlug) : ''; - // A grouped settings write shows the masked change list (input-derived, so it - // reads even while pending) instead of the generic "Applied: theme" result line. + // A grouped settings write shows the masked change list (input-derived, so it reads even while pending) instead of the generic "Applied: theme" result line. const visibleSummary = isSettingsWriteTool(toolName) ? settingsWriteSummary(input) : (resultSummary || inputSummary); diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx index 7b12377b..ac477e69 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx @@ -55,10 +55,7 @@ export const DefaultToolBubble: React.FC = ({ }) => { const c = useClaudeTokens(); const tc = useTermColors(); - // JS-driven mount reveal (see useMountReveal). The streaming pill itself glides - // in so a tool enters smoothly the moment it starts; when it commits, AgentChat - // sets suppressReveal on that same row so the hand-off doesn't re-animate what's - // already on screen. mcpCompact rows opt out (the group's row-fade handles them). + // JS-driven mount reveal (see useMountReveal). The streaming pill itself glides in so a tool enters smoothly the moment it starts; when it commits, AgentChat sets suppressReveal on that same row so the hand-off doesn't re-animate what's already on screen. mcpCompact rows opt out (the group's row-fade handles them). const reveal = useMountReveal(); const enterStyle = (!mcpCompact && !suppressReveal) ? reveal : {}; const canToggleDetails = !!inputSummary && !isStreaming; diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx index 44dfcde1..78f0a125 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx @@ -66,10 +66,7 @@ const ToolCallBubble: React.FC = React.memo( const isInvokeAgent = isInvokeAgentTool(toolName); const isCreateAgent = isCreateAgentTool(toolName); const browserAgentAutoExpand = isBrowserAgent && isPending && !isStreaming; - // While the call is still streaming we keep the body CLOSED: the args land in - // bursty clumps and force-painting them mid-stream is the jitter the user feels. - // The header pill (tool name + glow) is the calm "what's running" signal; the - // full args/output live behind the chevron once the call lands and is expanded. + // While the call is still streaming we keep the body CLOSED: the args land in bursty clumps and force-painting them mid-stream is the jitter the user feels. The header pill (tool name + glow) is the calm "what's running" signal; the full args/output live behind the chevron once the call lands and is expanded. const showBody = expanded || browserAgentAutoExpand; const resultContent = result?.content; diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx index 8be1a556..8da2895a 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx @@ -110,9 +110,7 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = my: 0.5, // contain: stops new tool rows from reflowing the whole transcript. contain: 'layout style', - // Ease in instead of popping when a tool group appears mid-turn. - // Transform+opacity only, so it rides the compositor and never nudges - // layout or the scroll position. No streaming twin, so no handoff flash. + // Ease in instead of popping when a tool group appears mid-turn. Transform+opacity only, so it rides the compositor and never nudges layout or the scroll position. No streaming twin, so no handoff flash. ...reveal, }} > diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/useMountReveal.ts b/frontend/src/app/pages/AgentChat/tool-bubbles/useMountReveal.ts index 65a7531d..fed27c5b 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/useMountReveal.ts +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/useMountReveal.ts @@ -23,10 +23,7 @@ export function useMountReveal(durationMs = 280, travelPx = 10) { const r = requestAnimationFrame(() => setShown(true)); return () => cancelAnimationFrame(r); }, []); - // No willChange: transform/opacity already composite in Chromium, and a - // permanent willChange would pin every tool bubble to its own layer for the - // life of a long transcript. The one-frame promotion hitch is imperceptible - // for a mount fade. + // No willChange: transform/opacity already composite in Chromium, and a permanent willChange would pin every tool bubble to its own layer for the life of a long transcript. The one-frame promotion hitch is imperceptible for a mount fade. return { opacity: shown ? 1 : 0, transform: shown ? 'translateY(0)' : `translateY(${travelPx}px)`, diff --git a/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts index 48b9959b..21d05f44 100644 --- a/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts +++ b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts @@ -3,8 +3,7 @@ import { useAppDispatch } from '@/shared/hooks'; import { streamStart, streamDelta } from '@/shared/state/streamingSlice'; import { addMessage, type AgentSession } from '@/shared/state/agentsSlice'; -// The first thing a new user reads. Written as a normal assistant turn (prose, no headings) so it -// streams in exactly like a real reply. No em-dashes. +// The first thing a new user reads. Written as a normal assistant turn (prose, no headings) so it streams in exactly like a real reply. No em-dashes. export const WELCOME_GREETING = "Hi, I'm OpenSwarm, your personal AI team. I can do just about anything right on your laptop, " + "so bring me anything: a tough problem, a half-formed idea, something you need to write. " + @@ -12,10 +11,7 @@ export const WELCOME_GREETING = const GREETING_MSG_ID = 'welcome-greeting'; -// Streams the first-run greeting in as a genuine assistant bubble: it rides the same streaming -// slice + smooth-reveal every real reply uses, then settles into a real message so the chips can -// follow. Pure UI, no LLM, no run: launchAndSendFirstMessage POSTs only the prompt, so this -// seeded message is dropped on the server swap and never reaches the backend. +// Streams the first-run greeting in as a genuine assistant bubble: it rides the same streaming slice + smooth-reveal every real reply uses, then settles into a real message so the chips can follow. Pure UI, no LLM, no run: launchAndSendFirstMessage POSTs only the prompt, so this seeded message is dropped on the server swap and never reaches the backend. export function useWelcomeGreeting( session: AgentSession | undefined, isDraft: boolean, diff --git a/frontend/src/app/pages/Dashboard/ChatBubbleTeardrop.tsx b/frontend/src/app/pages/Dashboard/ChatBubbleTeardrop.tsx index 34ff5c7a..9001be7a 100644 --- a/frontend/src/app/pages/Dashboard/ChatBubbleTeardrop.tsx +++ b/frontend/src/app/pages/Dashboard/ChatBubbleTeardrop.tsx @@ -1,9 +1,6 @@ import React from 'react'; -// Custom near-circular speech bubble with a teardrop tail at the -// bottom-left. The bubble body is a rounded square with corner radius -// ~half the body size, so it reads as a circle. Matches Image #57; MUI -// rounded chat glyphs either fill the bubble or omit the tail. +// Custom near-circular speech bubble with a teardrop tail at the bottom-left. The bubble body is a rounded square with corner radius ~half the body size, so it reads as a circle. Matches Image #57; MUI rounded chat glyphs either fill the bubble or omit the tail. export default function ChatBubbleTeardrop(props: { sx?: { fontSize?: number } }) { const size = props.sx?.fontSize ?? 18; return ( diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 29776210..2fb674e8 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -124,11 +124,7 @@ const DashboardToolbar = React.forwardRef( const [mode, setMode] = useState(defaultMode || 'agent'); const [model, setModel] = useState(defaultModel || 'sonnet'); const [thinkingLevel, setThinkingLevel] = useState<'off' | 'low' | 'medium' | 'high' | 'auto'>(defaultThinkingLevel || 'auto'); - // Snap to the persisted Settings defaults as soon as they arrive from the - // backend. Without the settingsLoaded guard, the effect fires against the - // Redux initialState ('sonnet') before the real default has loaded, and - // the settingsApplied flag then locks out the real default for the rest - // of the session , so new chats spawn under the stale value. + // Snap to the persisted Settings defaults as soon as they arrive from the backend. Without the settingsLoaded guard, the effect fires against the Redux initialState ('sonnet') before the real default has loaded, and the settingsApplied flag then locks out the real default for the rest of the session, so new chats spawn under the stale value. const settingsApplied = useRef(false); useEffect(() => { if (settingsLoaded && !settingsApplied.current) { @@ -148,11 +144,7 @@ const DashboardToolbar = React.forwardRef( } prevInputOpen.current = inputOpen; }, [inputOpen, settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]); - // Prefill-driven mode: a Build starter opens the composer in App Builder mode - // ('view-builder'); a non-Build starter (no prefillMode) falls back to the - // default. Gated on inputOpen + declared last so it wins the reset effects - // above regardless of settings-load timing. A later manual pick survives - // because none of these deps change on a pick. + // Prefill-driven mode: a Build starter opens the composer in App Builder mode ('view-builder'); a non-Build starter (no prefillMode) falls back to the default. Gated on inputOpen + declared last so it wins the reset effects above regardless of settings-load timing. A later manual pick survives because none of these deps change on a pick. useEffect(() => { if (!inputOpen || !settingsLoaded) return; setMode(prefillMode || defaultMode || 'agent'); @@ -267,9 +259,7 @@ const DashboardToolbar = React.forwardRef( setViewSearch(''); }, [viewPickerOpen, dispatch]); - // Opens the History popover on Chat history, with a tab to the Scheduled - // tasks run log. The calendar is a separate destination reached via the - // Schedule pill, never from here. + // Opens the History popover on Chat history, with a tab to the Scheduled tasks run log. The calendar is a separate destination reached via the Schedule pill, never from here. const handleOpenHistory = useCallback(() => { if (historyOpen) { setHistoryOpen(false); @@ -301,16 +291,13 @@ const DashboardToolbar = React.forwardRef( const autoSelectOnNew = useAppSelector((s) => s.settings.data.auto_select_mode_on_new_agent); const prevInputOpenRef = useRef(inputOpen); useEffect(() => { - // Collapsing the composer drops the selecting cursor but KEEPS the selected - // elements, so they persist across collapse/reopen like the draft text does. - // The selection is cleared on send via ChatInput's clearOwnerElements(ownerId). + // Collapsing the composer drops the selecting cursor but KEEPS the selected elements, so they persist across collapse/reopen like the draft text does. The selection is cleared on send via ChatInput's clearOwnerElements(ownerId). if (prevInputOpenRef.current && !inputOpen && elementSelection) { if (elementSelection.selectMode && elementSelection.activeOwnerId === TOOLBAR_OWNER_ID) { elementSelection.setSelectMode(false); } } - // Re-arm select mode on reopen without wiping any in-progress selection - // (mirrors the selector button, which only clears when switching owners). + // Re-arm select mode on reopen without wiping any in-progress selection (mirrors the selector button, which only clears when switching owners). if (!prevInputOpenRef.current && inputOpen && autoSelectOnNew && elementSelection) { elementSelection.setActiveOwnerId(TOOLBAR_OWNER_ID); elementSelection.setExcludeSelectId(null); @@ -452,11 +439,7 @@ const DashboardToolbar = React.forwardRef( }} > {inputOpen && !historyOpen ? ( - // historyOpen wins over the composer: clicking Schedule closes the - // composer via onCancel(), but that's a parent-state update that - // lands a render late, so without this guard the composer kept - // covering the calendar (the "Schedule does nothing" bug). - // data-onboarding-scope="dock" makes AC's per-agent resolver prefer this dock chat input over existing agent cards. + // historyOpen wins over the composer: clicking Schedule closes the composer via onCancel(), but that's a parent-state update that lands a render late, so without this guard the composer kept covering the calendar (the "Schedule does nothing" bug). data-onboarding-scope="dock" makes AC's per-agent resolver prefer this dock chat input over existing agent cards.
= ({ right: 0, zIndex: 10, pointerEvents: 'none', - // p: 3 (24px) was leaving a chunky air gap between the sidebar - // edge and the dashboard header that read as "two disconnected - // panels" rather than one continuous surface. 0.75 (6px) - // tightens the inset so the header floats just inside the - // content area without losing its breathing room from the - // top-most pixel. + // p: 3 (24px) was leaving a chunky air gap between the sidebar edge and the dashboard header that read as "two disconnected panels" rather than one continuous surface. 0.75 (6px) tightens the inset so the header floats just inside the content area without losing its breathing room from the top-most pixel. p: 0.75, pb: 0, background: `linear-gradient(to bottom, ${c.bg.page} 60%, transparent)`, diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 5b019efd..1bb88315 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -102,8 +102,7 @@ const DashboardCardLayer: React.FC = ({ const monitorCard = useAppSelector((s) => s.dashboardLayout.workflowsMonitorCard); const monitorWorkflowId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId); const monitorWorkflow = useAppSelector((s) => (monitorWorkflowId ? s.workflows.items[monitorWorkflowId] : undefined)); - // The monitor's workflow vanished (trashed/deleted) while open: tear the card - // + its tether down instead of leaving an orange line pointing at nothing. + // The monitor's workflow vanished (trashed/deleted) while open: tear the card + its tether down instead of leaving an orange line pointing at nothing. React.useEffect(() => { if (monitorCard && !monitorWorkflow) dispatch(closeWorkflowMonitor()); }, [monitorCard, monitorWorkflow, dispatch]); @@ -173,9 +172,7 @@ const DashboardCardLayer: React.FC = ({ exitTarget={exitTarget} isSelected={isSel} isHighlighted={highlightedCardId === sid} - // Only selected cards need the live drag delta; passing - // it to everyone broke memo equality for unselected - // cards on every mouse-move during multi-drag. + // Only selected cards need the live drag delta; passing it to everyone broke memo equality for unselected cards on every mouse-move during multi-drag. multiDragDelta={isSel ? multiDragDelta : null} onCardSelect={onCardSelect} onDragStart={onDragStart} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 2a04fbd7..6d544a83 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -11,9 +11,7 @@ import { } from '@/app/components/Onboarding/steps/skipPredicates'; import { STARTER_CATEGORIES } from '@/shared/starterCategories'; -// Returning-user empty state (the first-run greeting now lives in the auto-popped welcome -// chat). Quiet: a one-line prompt + the shared starter chips for users who can run, or a -// connect-a-model hint for users who can't. Two-level: category -> concrete prompts. +// Returning-user empty state (the first-run greeting now lives in the auto-popped welcome chat). Quiet: a one-line prompt + the shared starter chips for users who can run, or a connect-a-model hint for users who can't. Two-level: category -> concrete prompts. const DashboardEmptyState: React.FC<{ c: ClaudeTokens; onLaunch?: (prompt: string, mode: string, model: string) => void; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx index c4356ada..df5d8dd1 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx @@ -12,9 +12,7 @@ import { } from 'lucide-react'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -// Whole-word keyword -> icon. Looked up per title token (never substring), so -// "admin" can't trip the "ad" rule. Keep keys lowercase + singular; plurals -// are handled by the trailing-s strip in pickIcon. Add gerunds explicitly. +// Whole-word keyword -> icon. Looked up per title token (never substring), so "admin" can't trip the "ad" rule. Keep keys lowercase + singular; plurals are handled by the trailing-s strip in pickIcon. Add gerunds explicitly. const KEYWORDS: Record = { timer: Timer, pomodoro: Timer, stopwatch: Timer, countdown: Timer, break: Timer, clock: Clock, reminder: Clock, alarm: Clock, deadline: Clock, @@ -89,8 +87,7 @@ const DashboardGlyph: React.FC = ({ name, size = 16 }) => { return ; } - // No keyword hit: a tinted monogram of the first letter. Honest identity, - // never a misleading icon. A title with no latin letters falls back to the glyph. + // No keyword hit: a tinted monogram of the first letter. Honest identity, never a misleading icon. A title with no latin letters falls back to the glyph. const letter = title.match(/[a-z0-9]/i)?.[0]?.toUpperCase(); if (!letter) { return ; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx index b2ef62ba..63dc9453 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx @@ -124,8 +124,7 @@ const DashboardHeader: React.FC = ({ display: 'flex', alignItems: 'center', gap: 0.75, - // macOS-toolbar vibrancy: a faint translucent material + blur so the - // title stays legible over the dot grid without a hard box. + // macOS-toolbar vibrancy: a faint translucent material + blur so the title stays legible over the dot grid without a hard box. bgcolor: expanded ? c.bg.surface : `${c.bg.surface}40`, backdropFilter: 'blur(16px) saturate(180%)', WebkitBackdropFilter: 'blur(16px) saturate(180%)', @@ -170,9 +169,7 @@ const DashboardHeader: React.FC = ({ target={{ kind: 'dashboard', id: dashboardId, name: dashboardName || 'Dashboard' }} iconFontSize={15} onOpen={() => { - // Layout saves are debounced, so a just-added app/agent card may - // not be on disk yet. The export reads disk, flush the live - // layout now so Share captures the current board, not a stale one. + // Layout saves are debounced, so a just-added app/agent card may not be on disk yet. The export reads disk, flush the live layout now so Share captures the current board, not a stale one. if (!dashboardId) return; dispatch(saveLayout({ dashboardId, cards, viewCards, browserCards, workflowCards, workflowsHub, notes, expandedSessionIds })); }} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 626af4b8..85279bda 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -260,12 +260,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ interface OuterProps { sessionId: string; expanded: boolean; - // Stable getter , cards read pan/zoom on demand (drag math) instead of - // receiving them as props. Without this, every wheel/pan tick on the - // canvas re-rendered every card, even though the canvas root's CSS - // transform is what actually moves them visually. Cards only need the - // values inside drag callbacks; making it a ref-backed getter keeps - // pan/zoom out of memo equality entirely. + // Stable getter, cards read pan/zoom on demand (drag math) instead of receiving them as props. Without this, every wheel/pan tick on the canvas re-rendered every card, even though the canvas root's CSS transform is what actually moves them visually. Cards only need the values inside drag callbacks; making it a ref-backed getter keeps pan/zoom out of memo equality entirely. getCanvasState: () => { panX: number; panY: number; zoom: number }; spawnFrom?: { x: number; y: number; type?: 'branch' }; exitTarget?: { x: number; y: number }; @@ -319,12 +314,7 @@ const AgentCard: React.FC = ({ const modelsByProvider = useAppSelector((s) => s.models.byProvider); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]); - // Suppress the convert-suggestion glow when this chat is already entangled - // with a workflow. Two cases: - // (a) The session is one of a workflow's runner sessions, OR - // (b) The session is the source the workflow was originally derived - // from. Either way a fresh convert would just clone the workflow, - // which is confusing identity collapse. + // Suppress the convert-suggestion glow when this chat is already entangled with a workflow. Two cases: (a) The session is one of a workflow's runner sessions, OR (b) The session is the source the workflow was originally derived from. Either way a fresh convert would just clone the workflow, which is confusing identity collapse. const workflowRunsMap = useAppSelector((s) => s.workflows.runs); const workflowItems = useAppSelector((s) => s.workflows.items); const linkedWorkflowSidecarId = useAppSelector((s) => { @@ -338,8 +328,7 @@ const AgentCard: React.FC = ({ return null; }, [workflowItems, session.id]); const isWorkflowRunnerSession = useMemo(() => { - // A Test Agent (spawned to validate a workflow draft) isn't a chat to - // convert; it carries workflow_test_state. + // A Test Agent (spawned to validate a workflow draft) isn't a chat to convert; it carries workflow_test_state. if (session.workflow_test_state) return true; for (const arr of Object.values(workflowRunsMap || {})) { for (const r of arr || []) { @@ -390,9 +379,7 @@ const AgentCard: React.FC = ({ dispatch(fadeGlowingAgentCard(session.id)); }, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]); - // When the agent schedules a workflow from this chat, open it in the - // Workflows app. Baseline the count once on mount so historical schedules - // (e.g. after an app reload) don't re-open on their own. + // When the agent schedules a workflow from this chat, open it in the Workflows app. Baseline the count once on mount so historical schedules (e.g. after an app reload) don't re-open on their own. const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]); const baselineScheduleCountRef = useRef(null); const autoOpenedWorkflowIdsRef = useRef>(new Set()); @@ -420,8 +407,7 @@ const AgentCard: React.FC = ({ // Stash height during pan/drag/zoom; flush on gesture end so layout reconciles. let suppressedHeight: number | null = null; const ro = new ResizeObserver((entries) => { - // Short-circuit when dashboard is hidden , observer stays attached so - // the next resize after returning to the dashboard fires correctly. + // Short-circuit when dashboard is hidden, observer stays attached so the next resize after returning to the dashboard fires correctly. if (!isDashboardActiveRef.current) return; // Re-measuring per streamed character mid-pan was forcing Dashboard re-renders via setMeasuredHeightsTick. if (isCanvasInteractionActive()) { @@ -733,10 +719,7 @@ const AgentCard: React.FC = ({ }} sx={{ position: 'relative', - // contain: streaming chat updates inside don't reflow the dashboard. - // Skipping `paint` here because the highlighted/selected/glow - // boxShadows legitimately extend past the card border , `paint` - // containment would clip those visuals. + // contain: streaming chat updates inside don't reflow the dashboard. Skipping `paint` here because the highlighted/selected/glow boxShadows legitimately extend past the card border, `paint` containment would clip those visuals. contain: 'layout style', // Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas. willChange: 'transform', diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 807cfab9..e6e56722 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -76,26 +76,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ { dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, ]; -// Windows gets the real, agent-controllable + CDP, same as Mac. -// -// History: the tag mount used to segfault the renderer during Chromium's -// commit phase on the old CastLabs Electron 40 build (0xC0000005, since 1.1.55, same -// crash family as the ablated and Framer-Motion subtrees), so -// Windows fell back to a non-scriptable iframe (no CDP, and most sites send -// X-Frame-Options) which the agent can't drive. The Electron 42 bump (v42.0.0+wvcus) -// fixed the segfault: faithful in-process probes on the real 42 binary mount the -// webview - including two at once inside a transformed/contained canvas, with real -// HTTPS navigation and reload churn - with zero host-renderer crash. -// -// Crash-safe by construction (electron/CLAUDE.md: mitigations must fail quiet in BOTH -// directions, and crash guards never boot-loop). If some Windows config still -// segfaults on mount, a pending marker - armed synchronously during the first -// browser-card render, i.e. before the commits, see armWindowsWebviewPending -// - survives the crash. A leftover marker at the next launch means that mount never -// reached dom-ready, so we count it and stand down to the safe iframe this launch; -// after WIN_WV_MAX such crashes we stay on the iframe for good. A clean dom-ready -// clears the marker and the counter. Escape hatch: openswarm_win_webview_off='1' -// forces the iframe; clear openswarm_win_webview_crashes to retry after a lockout. +// Windows gets the real, agent-controllable + CDP, same as Mac. History: the tag mount used to segfault the renderer during Chromium's commit phase on the old CastLabs Electron 40 build (0xC0000005, since 1.1.55, same crash family as the ablated and Framer-Motion subtrees), so Windows fell back to a non-scriptable iframe (no CDP, and most sites send X-Frame-Options) which the agent can't drive. The Electron 42 bump (v42.0.0+wvcus) fixed the segfault: faithful in-process probes on the real 42 binary mount the webview - including two at once inside a transformed/contained canvas, with real HTTPS navigation and reload churn - with zero host-renderer crash. Crash-safe by construction (electron/CLAUDE.md: mitigations must fail quiet in BOTH directions, and crash guards never boot-loop). If some Windows config still segfaults on mount, a pending marker - armed synchronously during the first browser-card render, i.e. before the commits, see armWindowsWebviewPending - survives the crash. A leftover marker at the next launch means that mount never reached dom-ready, so we count it and stand down to the safe iframe this launch; after WIN_WV_MAX such crashes we stay on the iframe for good. A clean dom-ready clears the marker and the counter. Escape hatch: openswarm_win_webview_off='1' forces the iframe; clear openswarm_win_webview_crashes to retry after a lockout. const WIN_WV_OFF = 'openswarm_win_webview_off'; const WIN_WV_PENDING = 'openswarm_win_webview_pending'; const WIN_WV_CRASHES = 'openswarm_win_webview_crashes'; @@ -107,8 +88,7 @@ function windowsWebviewEnabled(): boolean { const crashes = parseInt(localStorage.getItem(WIN_WV_CRASHES) || '0', 10) || 0; if (crashes >= WIN_WV_MAX) return false; if (localStorage.getItem(WIN_WV_PENDING)) { - // A webview mounted last launch but never reached dom-ready: it crashed on - // commit. Count it and use the safe iframe this launch (retry next launch). + // A webview mounted last launch but never reached dom-ready: it crashed on commit. Count it and use the safe iframe this launch (retry next launch). localStorage.removeItem(WIN_WV_PENDING); localStorage.setItem(WIN_WV_CRASHES, String(crashes + 1)); console.warn(`[win-webview] mount crashed last launch (${crashes + 1}/${WIN_WV_MAX}); using the safe iframe this launch.`); @@ -120,9 +100,7 @@ function windowsWebviewEnabled(): boolean { } } -// Armed once, synchronously, during the first browser-card render so it persists -// even if the commit segfaults (a ref/effect would run too late, after the -// crash). Cleared on dom-ready by markWindowsWebviewSurvived. No-op on Mac / iframe. +// Armed once, synchronously, during the first browser-card render so it persists even if the commit segfaults (a ref/effect would run too late, after the crash). Cleared on dom-ready by markWindowsWebviewSurvived. No-op on Mac / iframe. let _winWvPendingArmed = false; function armWindowsWebviewPending(): void { if (_winWvPendingArmed) return; @@ -130,8 +108,7 @@ function armWindowsWebviewPending(): void { try { localStorage.setItem(WIN_WV_PENDING, String(Date.now())); } catch {} } -// Clears the pending marker + crash counter once a webview survives to dom-ready; -// no-op on Mac (never set). +// Clears the pending marker + crash counter once a webview survives to dom-ready; no-op on Mac (never set). function markWindowsWebviewSurvived(): void { try { localStorage.removeItem(WIN_WV_PENDING); @@ -193,8 +170,7 @@ const BrowserCard: React.FC = ({ }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - // Read via ref inside the webview-attach effect so a new onDoubleClick identity - // doesn't re-run that effect (which would re-register the webview). + // Read via ref inside the webview-attach effect so a new onDoubleClick identity doesn't re-run that effect (which would re-register the webview). const onDoubleClickRef = useRef(onDoubleClick); onDoubleClickRef.current = onDoubleClick; const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); @@ -202,8 +178,7 @@ const BrowserCard: React.FC = ({ const elementSelectionCtx = useElementSelection(); const isElementSelectMode = elementSelectionCtx?.selectMode ?? false; - // Memoized so the all-sessions scan reruns only when sessions actually change, - // not on every layout/drag dispatch at 60Hz. + // Memoized so the all-sessions scan reruns only when sessions actually change, not on every layout/drag dispatch at 60Hz. const selectBrowserAgentSession = React.useMemo( () => createSelector( [(state: { agents: { sessions: Record } }) => state.agents.sessions], @@ -222,11 +197,7 @@ const BrowserCard: React.FC = ({ const suspendedSnap = useAppSelector((state) => state.dashboardLayout.suspendedBrowserCards[browserId]); const endingState = useAppSelector((state) => state.dashboardLayout.endingBrowserCards[browserId]); - // Arm the Windows webview crash-safety marker synchronously, before React commits - // the below. Cleared on dom-ready; a leftover marker next launch tells - // windowsWebviewEnabled() the mount crashed, so it falls back to the iframe. - // MUST skip parked cards: they render no webview, so dom-ready never fires and a - // stale marker reads as a phantom crash that locks Windows out of webviews. + // Arm the Windows webview crash-safety marker synchronously, before React commits the below. Cleared on dom-ready; a leftover marker next launch tells windowsWebviewEnabled() the mount crashed, so it falls back to the iframe. MUST skip parked cards: they render no webview, so dom-ready never fires and a stale marker reads as a phantom crash that locks Windows out of webviews. if (isElectron && isWindows && !suspendedSnap) armWindowsWebviewPending(); const activity = useBrowserActivity(browserId); @@ -272,9 +243,7 @@ const BrowserCard: React.FC = ({ if (suspendedSnap) initializedTabs.current.clear(); }, [suspendedSnap]); - // Spawned cards get marked "ending" by WebSocketManager when the parent agent - // finishes; show the fade pill for ~3s, then dispatch the real remove. Keep - // clears the flag and the cleanup below cancels the pending remove. + // Spawned cards get marked "ending" by WebSocketManager when the parent agent finishes; show the fade pill for ~3s, then dispatch the real remove. Keep clears the flag and the cleanup below cancels the pending remove. useEffect(() => { if (!endingState) return; const timer = setTimeout(() => { @@ -299,8 +268,7 @@ const BrowserCard: React.FC = ({ initializedTabs.current.add(tabId); const targetUrl = tab.url; const doLoad = () => { - // Reaching dom-ready proves the webview survived Chromium's commit phase - // (the historical Windows mount segfault). Clear the crash-safety marker. + // Reaching dom-ready proves the webview survived Chromium's commit phase (the historical Windows mount segfault). Clear the crash-safety marker. if (isWindows) markWindowsWebviewSurvived(); wv.loadURL(targetUrl).catch(() => {}); try { @@ -343,8 +311,7 @@ const BrowserCard: React.FC = ({ }), ); } else if (e?.channel === 'canvas-wheel-pan') { - // Plain wheel inside an unselected webview never bubbles out; the - // preload forwards it here so the dashboard canvas can pan. + // Plain wheel inside an unselected webview never bubbles out; the preload forwards it here so the dashboard canvas can pan. const payload = e.args?.[0] || {}; window.dispatchEvent( new CustomEvent('openswarm:canvas-wheel-pan', { @@ -766,8 +733,7 @@ const BrowserCard: React.FC = ({ position: 'absolute', // contain: webview repaints don't shake neighbor cards. contain: 'layout style', - // Own compositor layer so hover/paint invalidations stay - // contained to this card. See AgentCard for full rationale. + // Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale. willChange: 'transform', left: displayX, top: displayY, @@ -1184,9 +1150,7 @@ const BrowserCard: React.FC = ({ border: 'none', visibility: tab.id === activeTabId ? 'visible' : 'hidden', zIndex: tab.id === activeTabId ? 1 : 0, - // Only during select mode does the page go click-through, so the element - // picker can grab the whole card from anywhere instead of just the header - // (a live webview swallows host clicks). Off select mode = live for browsing. + // Only during select mode does the page go click-through, so the element picker can grab the whole card from anywhere instead of just the header (a live webview swallows host clicks). Off select mode = live for browsing. pointerEvents: isElementSelectMode ? 'none' : 'auto', }} /> diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserReplayOverlay.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserReplayOverlay.tsx index be1edc9c..6b4a8918 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserReplayOverlay.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserReplayOverlay.tsx @@ -123,6 +123,5 @@ function BrowserReplayOverlay({ ); } -// memo: re-render only when the inputs actually change (cheap, but free insurance -// against parent BrowserCard re-renders during an agent run). +// memo: re-render only when the inputs actually change (cheap, but free insurance against parent BrowserCard re-renders during an agent run). export default React.memo(BrowserReplayOverlay); diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index adc22f07..784310d0 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -67,10 +67,7 @@ interface Props { onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void; } -// The app card's loading state while its runtime spins up. One soft pulse, calm -// copy, and an honest hint only after 9s, a freshly-imported app installs its -// deps on first open, which is the slow case worth explaining instead of leaving -// the user staring at a dead screen. +// The app card's loading state while its runtime spins up. One soft pulse, calm copy, and an honest hint only after 9s, a freshly-imported app installs its deps on first open, which is the slow case worth explaining instead of leaving the user staring at a dead screen. const BootingBody: React.FC = () => { const c = useClaudeTokens(); const [slow, setSlow] = useState(false); @@ -135,10 +132,7 @@ const DashboardViewCard: React.FC = ({ const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); - // Reload the preview when the session finishes a turn: React holds the - // ErrorBoundary's snag page until a reload, so without this the user keeps - // seeing the old error even after the agent fixed it. The overlay lingers - // through the reload (finishing) so the stale page never flashes. + // Reload the preview when the session finishes a turn: React holds the ErrorBoundary's snag page until a reload, so without this the user keeps seeing the old error even after the agent fixed it. The overlay lingers through the reload (finishing) so the stale page never flashes. const linkedStatus = useAppSelector( (s) => (output.session_id ? s.agents.sessions[output.session_id]?.status : undefined), ); @@ -568,10 +562,7 @@ const DashboardViewCard: React.FC = ({ export default React.memo(DashboardViewCard); -// Calm overlay shown while the App Builder chat that owns this output is -// actively editing it (and through the post-turn reload). Hides whatever -// transient half-broken state the agent might be writing through so the -// user sees "Building..." instead of an error iframe. Fades in/out. +// Calm overlay shown while the App Builder chat that owns this output is actively editing it (and through the post-turn reload). Hides whatever transient half-broken state the agent might be writing through so the user sees "Building..." instead of an error iframe. Fades in/out. const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => { const c = useClaudeTokens(); return ( @@ -634,9 +625,7 @@ const DashboardOutputPreview: React.FC<{ isNewMode, }); - // Declared above every early-return below so React's hook order stays - // stable; moving it below would trigger "Rendered more hooks than during - // the previous render." + // Declared above every early-return below so React's hook order stays stable; moving it below would trigger "Rendered more hooks than during the previous render." const handleConsoleMessage = useCallback((level: string, text: string) => { if (!text || !workspaceId) return; const tok = getAuthToken(); @@ -661,8 +650,7 @@ const DashboardOutputPreview: React.FC<{ }).catch(() => {}); }, [workspaceId]); - // An orphaned record (files deleted on disk) used to render the raw 404 JSON - // inside the card, or spin on "Starting preview" forever; probe once instead. + // An orphaned record (files deleted on disk) used to render the raw 404 JSON inside the card, or spin on "Starting preview" forever; probe once instead. const [filesMissing, setFilesMissing] = useState(false); useEffect(() => { let cancelled = false; diff --git a/frontend/src/app/pages/Dashboard/geometry/contentBounds.ts b/frontend/src/app/pages/Dashboard/geometry/contentBounds.ts index d210a644..98182677 100644 --- a/frontend/src/app/pages/Dashboard/geometry/contentBounds.ts +++ b/frontend/src/app/pages/Dashboard/geometry/contentBounds.ts @@ -13,8 +13,7 @@ export interface ContentBounds { maxY: number; } -// Bounding box over agent + view + browser cards (notes intentionally -// excluded, same as before). Returns undefined for an empty canvas. +// Bounding box over agent + view + browser cards (notes intentionally excluded, same as before). Returns undefined for an empty canvas. export function computeContentBounds( cards: Record, viewCards: Record, diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 712db8eb..5215c470 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -58,9 +58,7 @@ export function elbowPath(x1: number, y1: number, x2: number, y2: number): strin type Anchor = { x: number; y: number; side: 'left' | 'right' | 'top' | 'bottom' }; type CanvasRect = { x: number; y: number; width: number; height: number }; -// Where the ray from a rect's center toward (tx,ty) crosses the rect border. -// Pins a tether endpoint to the card edge facing the other card, so it can -// never float in empty space the way nearest-corner anchoring could. +// Where the ray from a rect's center toward (tx,ty) crosses the rect border. Pins a tether endpoint to the card edge facing the other card, so it can never float in empty space the way nearest-corner anchoring could. function borderPoint(x: number, y: number, w: number, h: number, tx: number, ty: number): { x: number; y: number } { const cx = x + w / 2; const cy = y + h / 2; @@ -156,9 +154,7 @@ export function useTethers({ }; }).filter(Boolean) as Tether[]; - // One tether builder for both browser and view cards: the anchor-pairing - // and elbow/vertical path are identical; only the destination card map and - // the key prefix differ, so the resolved dst card is passed in. + // One tether builder for both browser and view cards: the anchor-pairing and elbow/vertical path are identical; only the destination card map and the key prefix differ, so the resolved dst card is passed in. function cardTether( dst: { x: number; y: number; width: number; height: number } | undefined, dstId: string, @@ -411,14 +407,12 @@ export function useTethers({ }); } - // Run Monitor tether: the Workflows window to its spawned live-run card. - // Same border-anchor + elbow math as the sidecar "Watching" arrow. + // Run Monitor tether: the Workflows window to its spawned live-run card. Same border-anchor + elbow math as the sidecar "Watching" arrow. const monitorTethers: Tether[] = []; if (workflowsHub && workflowsMonitorCard) { let hubX = workflowsHub.x, hubY = workflowsHub.y; let monX = workflowsMonitorCard.x, monY = workflowsMonitorCard.y; - // Track live drag so the line follows the card in real time instead of - // snapping into place on drop (same mechanism as the agent->browser tether). + // Track live drag so the line follows the card in real time instead of snapping into place on drop (same mechanism as the agent->browser tether). if (liveDragInfo) { if (liveDragInfo.cardId === 'workflows-hub') { hubX += liveDragInfo.dx; hubY += liveDragInfo.dy; } if (liveDragInfo.cardId === 'workflows-monitor') { monX += liveDragInfo.dx; monY += liveDragInfo.dy; } @@ -431,8 +425,7 @@ export function useTethers({ const b = borderPoint(monRect.x, monRect.y, monRect.width, monRect.height, hubC.x, hubC.y); const midX = a.x + (b.x - a.x) / 2; const midY = a.y + (b.y - a.y) / 2; - // The label box is left-anchored at labelX (rect starts there and grows - // right), so shift left by half the text width to truly center it on the line. + // The label box is left-anchored at labelX (rect starts there and grows right), so shift left by half the text width to truly center it on the line. monitorTethers.push({ key: 'workflows-monitor', path: elbowPath(a.x, a.y, b.x, b.y), @@ -443,8 +436,7 @@ export function useTethers({ }); } - // Index outputs by their owning session so the per-session lookup below - // doesn't scan the whole outputs map for every view-builder chat. + // Index outputs by their owning session so the per-session lookup below doesn't scan the whole outputs map for every view-builder chat. const outputsBySession = new Map(); for (const o of Object.values(outputs)) { if (!o.session_id) continue; @@ -473,8 +465,6 @@ export function useTethers({ } return [...agentTethers, ...browserTethers, ...workflowTethers, ...viewTethers, ...monitorTethers]; - // measuredHeightsTick re-runs the memo once ResizeObserver reports a new - // height after a collapse (the ref read is invisible to the dep checker). - // eslint-disable-next-line react-hooks/exhaustive-deps + // measuredHeightsTick re-runs the memo once ResizeObserver reports a new height after a collapse (the ref read is invisible to the dep checker). eslint-disable-next-line react-hooks/exhaustive-deps }, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel]); } diff --git a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts index bcb03883..edfd7ffa 100644 --- a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts +++ b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts @@ -1,9 +1,7 @@ import { store } from '@/shared/state/store'; import type { CardType } from '../hooks/state/useDashboardSelection'; -// Reads a card's rect straight from the live Redux store (collapsed height, -// which is what the zoom math wants). Module-level + store.getState() so the -// callback can stay stable across renders. +// Reads a card's rect straight from the live Redux store (collapsed height, which is what the zoom math wants). Module-level + store.getState() so the callback can stay stable across renders. export function getCardRect(id: string, type: CardType): { x: number; y: number; width: number; height: number } | undefined { const layoutState = store.getState().dashboardLayout; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts index 242c2be4..44176e24 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts @@ -89,9 +89,7 @@ export function useArrowNav({ // Compute which directions have neighbors from the focused card const neighborDirections = useMemo(() => { - // Lowered the zoom floor from 0.9 to 0.4 so arrow nav still works - // when users zoom out to see the whole canvas. Below 0.4 the cards - // are too small to be a useful navigation target. + // Lowered the zoom floor from 0.9 to 0.4 so arrow nav still works when users zoom out to see the whole canvas. Below 0.4 the cards are too small to be a useful navigation target. if (!focusedCardId || zoom < 0.4) return { left: false, right: false, up: false, down: false }; return { left: !!findNearestCard(focusedCardId, 'left'), @@ -112,19 +110,14 @@ export function useArrowNav({ canvasZoomRef.current = zoom; useEffect(() => { - // Helper: is the currently-focused element a text-entry field the - // user is actively editing? We only want to suppress dashboard - // navigation when the user is genuinely typing, not just because an - // input somewhere happens to have focus from a click long ago. + // Helper: is the currently-focused element a text-entry field the user is actively editing? We only want to suppress dashboard navigation when the user is genuinely typing, not just because an input somewhere happens to have focus from a click long ago. const isActivelyEditing = (target: EventTarget | null): boolean => { const el = (target as HTMLElement) || (document.activeElement as HTMLElement | null); if (!el) return false; const tag = el.tagName; const editable = (el as any).isContentEditable; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !editable) return false; - // Only suppress when the input actually has content to navigate - // within. An empty input doesn't need arrow keys for cursor - // movement, so we can safely repurpose arrows for dashboard nav. + // Only suppress when the input actually has content to navigate within. An empty input doesn't need arrow keys for cursor movement, so we can safely repurpose arrows for dashboard nav. const val = (el as HTMLInputElement | HTMLTextAreaElement).value; if (typeof val === 'string' && val.length === 0) return false; if (editable && (el.textContent ?? '').length === 0) return false; @@ -134,8 +127,7 @@ export function useArrowNav({ const handleKey = (e: KeyboardEvent) => { if (!isActive) return; // Don't fire shortcuts when dashboard is hidden - // Escape blurs any active input and restores focus to the canvas , - // so you can quickly "unstick" keyboard focus and start navigating. + // Escape blurs any active input and restores focus to the canvas, so you can quickly "unstick" keyboard focus and start navigating. if (e.key === 'Escape') { const active = document.activeElement as HTMLElement | null; const tag = active?.tagName; @@ -160,8 +152,7 @@ export function useArrowNav({ // Lowered zoom floor from 0.9 → 0.4 so nav still works zoomed out if (canvasZoomRef.current < 0.4) return; - // If no card is focused, pick the front-most one as a fallback so - // nav works after the user clicked on empty canvas. + // If no card is focused, pick the front-most one as a fallback so nav works after the user clicked on empty canvas. let currentFocused = focusedCardIdRef.current; if (!currentFocused) { const anyCardId = Object.keys(cards)[0] || Object.keys(viewCards)[0] || Object.keys(browserCards)[0]; @@ -174,7 +165,7 @@ export function useArrowNav({ const target = findNearestCard(currentFocused, direction); if (!target) { - // No card in that direction , shake + // No card in that direction, shake if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current); setShakeDirection(direction); shakeTimerRef.current = setTimeout(() => { @@ -199,9 +190,7 @@ export function useArrowNav({ }, 100); }; - // Capture phase so we beat MUI Menus/Selects that also listen for - // arrows. We still bail early on isActivelyEditing, so this doesn't - // interfere with typing. + // Capture phase so we beat MUI Menus/Selects that also listen for arrows. We still bail early on isActivelyEditing, so this doesn't interfere with typing. window.addEventListener('keydown', handleKey, true); return () => window.removeEventListener('keydown', handleKey, true); }, [findNearestCard, getCardRect, canvasActions, dispatch, isActive, cards, viewCards, browserCards, setFocusedCardId]); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 6db17045..20fdfb6c 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -212,9 +212,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: }; const scheduleWheelFlush = () => { - // Mark the canvas as actively-interacting and (re)arm the idle - // timer. Any ResizeObserver / streaming reconciler that checks the - // flag will bail until the user's gesture goes quiet for ~140ms. + // Mark the canvas as actively-interacting and (re)arm the idle timer. Any ResizeObserver / streaming reconciler that checks the flag will bail until the user's gesture goes quiet for ~140ms. setCanvasInteractionActive(true); if (wheelIdleTimer != null) clearTimeout(wheelIdleTimer); wheelIdleTimer = setTimeout(() => { @@ -225,16 +223,14 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: wheelRafId = requestAnimationFrame(flushWheel); }; - // Cache "is this element a scrollable child" decision per node. The - // Cache getComputedStyle ancestor walks; uncached was the dominant cost of trackpad two-finger nav. ResizeObserver below invalidates on scroll-capacity change. + // Cache "is this element a scrollable child" decision per node. The Cache getComputedStyle ancestor walks; uncached was the dominant cost of trackpad two-finger nav. ResizeObserver below invalidates on scroll-capacity change. const scrollableCache: WeakMap = new WeakMap(); const onWheel = (e: WheelEvent) => { // Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not const isPinchZoom = e.ctrlKey || e.metaKey; - // Let scrollable children handle the event when appropriate, - // but fall through to canvas pan if the child is at its scroll boundary. + // Let scrollable children handle the event when appropriate, but fall through to canvas pan if the child is at its scroll boundary. const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; const dx = e.deltaMode === 1 ? e.deltaX * 40 : e.deltaX; let target = e.target as HTMLElement | null; @@ -262,9 +258,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const canScrollY = target.scrollHeight > target.clientHeight; const canScrollX = target.scrollWidth > target.clientWidth; - // Horizontal-dominant gestures over a container that only scrolls - // vertically (e.g., chat) should pan the canvas instead of being - // silently absorbed by the child's no-op horizontal handling. + // Horizontal-dominant gestures over a container that only scrolls vertically (e.g., chat) should pan the canvas instead of being silently absorbed by the child's no-op horizontal handling. if (Math.abs(dx) > Math.abs(dy) && !canScrollX) { target = target.parentElement; continue; @@ -293,10 +287,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: } if (isPinchZoom) { - // Pinch gesture → accumulate zoom deltas + last cursor position. - // factor = 2^(-Σdy·s) which equals the product of per-event - // factors, so accumulating dy is mathematically identical to - // applying each event one at a time. + // Pinch gesture → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time. const rect = el.getBoundingClientRect(); pendingZoomDy += dy; pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; @@ -328,9 +319,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: }; window.addEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); - // Plain wheel inside a webview can't bubble out either; the preload - // forwards horizontal-dominant scrolls as a pan when the guest page - // has nothing to scroll horizontally, plus middle-mouse drag deltas. + // Plain wheel inside a webview can't bubble out either; the preload forwards horizontal-dominant scrolls as a pan when the guest page has nothing to scroll horizontally, plus middle-mouse drag deltas. const onForwardedPan = (e: Event) => { const detail = (e as CustomEvent).detail || {}; const dy = detail.deltaMode === 1 ? (detail.deltaY ?? 0) * 40 : (detail.deltaY ?? 0); @@ -392,8 +381,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const dx = e.clientX - start.x; const dy = e.clientY - start.y; - // Velocity history is per-event so inertia stays accurate on - // mouseup. Cheap; just pushes to a length-5 ring buffer. + // Velocity history is per-event so inertia stays accurate on mouseup. Cheap; just pushes to a length-5 ring buffer. const now = performance.now(); const history = velocityHistoryRef.current; history.push({ x: e.clientX, y: e.clientY, t: now }); @@ -406,8 +394,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: }, [flushDrag]); const handleMouseUp = useCallback(() => { - // Apply any pending drag delta synchronously so the final position - // matches where the cursor was released, then drop the scheduled RAF. + // Apply any pending drag delta synchronously so the final position matches where the cursor was released, then drop the scheduled RAF. if (dragRafRef.current != null) { cancelAnimationFrame(dragRafRef.current); dragRafRef.current = null; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts index 3668ac48..9fc47003 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCardDrag.ts @@ -29,12 +29,7 @@ export function useCardDrag({ }: UseCardDragArgs) { const dispatch = useAppDispatch(); - // Notify the currently dragging card (if any) that pan/zoom changed so - // it can re-pin to the cursor. useEffect rather than render-body - // dispatchEvent: side effects during render are a React anti-pattern - // and can fire twice in strict mode. Effect runs after commit, so - // exactly once per real pan/zoom delta. Edge-pan mutates pan via - // canvasActions.setState below, so the dispatch lives in the same hook. + // Notify the currently dragging card (if any) that pan/zoom changed so it can re-pin to the cursor. useEffect rather than render-body dispatchEvent: side effects during render are a React anti-pattern and can fire twice in strict mode. Effect runs after commit, so exactly once per real pan/zoom delta. Edge-pan mutates pan via canvasActions.setState below, so the dispatch lives in the same hook. useEffect(() => { window.dispatchEvent(new Event('openswarm:canvas-pan-changed')); }, [panX, panY, zoom]); @@ -135,8 +130,7 @@ export function useCardDrag({ setLiveDragInfo(null); }, [selection, dispatch, stopEdgePan]); - // dragStartPanRef is kept for parity with the pre-split inline code; it - // was wired up for a future edge-pan compensation that never landed. + // dragStartPanRef is kept for parity with the pre-split inline code; it was wired up for a future edge-pan compensation that never landed. void dragStartPanRef; return { diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index 544f9ccd..8c126eb0 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -52,17 +52,13 @@ export function useDashboardInteractions({ selection.selectCard(id, type, false); dispatch(bringToFront({ id, type })); - // The Workflows window is an app you click around inside, not a card you - // re-center every tap. Single-click only raises + selects it; double-click - // still zoom-to-fits (handleCardDoubleClick). Without this, clicking any - // button inside it yanked the canvas into a re-zoom. + // The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom. if (type === 'workflows-hub' || type === 'workflows-monitor') return; const alreadyExpanded = type === 'agent' && expandedSessionIds.includes(id); if (alreadyExpanded) { - // Delay single-click collapse so double-click can override. - // Double-click handler (handleCardDoubleClick) clears clickTimerRef. + // Delay single-click collapse so double-click can override. Double-click handler (handleCardDoubleClick) clears clickTimerRef. clickTimerRef.current = setTimeout(() => { clickTimerRef.current = null; dispatch(collapseSession(id)); @@ -79,9 +75,7 @@ export function useDashboardInteractions({ const rect = getCardRect(id, type); if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined); setTimeout(() => { - // Don't blur an input/textarea/contentEditable the user is typing in - // (e.g. a workflow card's embedded chat); the click that selected the - // card also focused the field, and blurring it kills the cursor. + // Don't blur an input/textarea/contentEditable the user is typing in (e.g. a workflow card's embedded chat); the click that selected the card also focused the field, and blurring it kills the cursor. const active = document.activeElement as HTMLElement | null; if (!active) return; const tag = active.tagName; @@ -111,8 +105,7 @@ export function useDashboardInteractions({ if (e.button !== 0) return; if (isCardTarget(e.target, e.currentTarget)) return; - // Canvas click , drop any lingering input focus so arrow-key nav - // works immediately without the user having to press Escape first. + // Canvas click, drop any lingering input focus so arrow-key nav works immediately without the user having to press Escape first. const active = document.activeElement as HTMLElement | null; const activeTag = active?.tagName; if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (active as any)?.isContentEditable) { @@ -168,9 +161,7 @@ export function useDashboardInteractions({ const rect = getCardRect(id, type); if (rect) canvas.actions.fitToCards([rect], 1.15, true); setTimeout(() => { - // Don't blur an input/textarea/contentEditable the user is typing in - // (e.g. a workflow card's embedded chat); the click that selected the - // card also focused the field, and blurring it kills the cursor. + // Don't blur an input/textarea/contentEditable the user is typing in (e.g. a workflow card's embedded chat); the click that selected the card also focused the field, and blurring it kills the cursor. const active = document.activeElement as HTMLElement | null; if (!active) return; const tag = active.tagName; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts index c6d53bcc..387a0a98 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts @@ -94,8 +94,7 @@ export function useDashboardShortcuts({ return () => window.removeEventListener('keydown', handleDelete); }, [selection, dispatch]); - // Cmd/Ctrl+A selects every card so it can be deleted in one go. Skipped - // inside text fields so Cmd+A there still selects text, not cards. + // Cmd/Ctrl+A selects every card so it can be deleted in one go. Skipped inside text fields so Cmd+A there still selects text, not cards. useEffect(() => { const handleSelectAll = (e: KeyboardEvent) => { if (!isActive) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts index 2bdd7ddb..123aa5d2 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts @@ -54,9 +54,7 @@ export function useOverlayScrollPassthrough(active: boolean) { node.scrollWidth > node.clientWidth && (cs.overflowX === 'auto' || cs.overflowX === 'scroll'); - // Horizontal-dominant gesture over a vertically-only scrollable - // container: don't absorb it (scrollBy with dx would be a no-op). - // Let it bubble to the canvas wheel handler so the canvas pans. + // Horizontal-dominant gesture over a vertically-only scrollable container: don't absorb it (scrollBy with dx would be a no-op). Let it bubble to the canvas wheel handler so the canvas pans. if (horizontalDominant && !canScrollX) { node = node.parentElement; continue; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts index f80f61c3..036e9188 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useWebviewSuspend.ts @@ -12,16 +12,13 @@ import { getActivity } from '@/shared/browserCommandHandler'; const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron'); const SETTLE_MS = 800; -// Hysteresis: suspend only well past the edge, resume just past it, so a card -// sitting on the boundary never flaps between webview and snapshot. +// Hysteresis: suspend only well past the edge, resume just past it, so a card sitting on the boundary never flaps between webview and snapshot. const SUSPEND_MARGIN_PX = 320; const RESUME_MARGIN_PX = 96; const SNAPSHOT_MAX_W = 1024; -// Below this on-screen width a live page is indistinguishable from its placeholder, -// so booted-parked cards on a zoomed-out canvas stay parked until zoomed into. +// Below this on-screen width a live page is indistinguishable from its placeholder, so booted-parked cards on a zoomed-out canvas stay parked until zoomed into. const RESUME_MIN_CARD_PX = 220; -// Hard ceiling on simultaneous live webviews; past it the farthest-from-center -// non-agent card gets parked, so heavy pages degrade gracefully instead of OOMing. +// Hard ceiling on simultaneous live webviews; past it the farthest-from-center non-agent card gets parked, so heavy pages degrade gracefully instead of OOMing. const MAX_LIVE_WEBVIEWS = 8; interface Viewport { @@ -74,8 +71,7 @@ export function useWebviewSuspend( const suspended = useAppSelector((s) => s.dashboardLayout.suspendedBrowserCards); const vpRef = useRef({ panX, panY, zoom, vpW: 1200, vpH: 800 }); - // Window resize changes the viewport without touching pan/zoom/cards; tick so - // the evaluation below reruns, or a shrunken window never suspends anything. + // Window resize changes the viewport without touching pan/zoom/cards; tick so the evaluation below reruns, or a shrunken window never suspends anything. const [resizeTick, setResizeTick] = useState(0); useEffect(() => { if (!isElectron) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 1cba1dc2..886e247b 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -149,8 +149,7 @@ export function useAgentSpawn({ } const config: AgentConfig = { name: 'New chat', model, mode, dashboard_id: dashboardId }; - // Editing an existing app: bind the launch to it so the backend edits in - // place instead of seeding a duplicate empty app (App Builder mode only). + // Editing an existing app: bind the launch to it so the backend edits in place instead of seeding a duplicate empty app (App Builder mode only). if (selectedAppIds?.length) config.selected_app_output_ids = selectedAppIds; dispatch( @@ -178,14 +177,7 @@ export function useAgentSpawn({ if (selectedBrowserIds.length === 1) { const bc = store.getState().dashboardLayout.browserCards[selectedBrowserIds[0]]; if (bc) { - // Use placeCard (collision-aware) instead of - // setCardPosition (blind setter). The "left of the - // browser" anchor is the IDEAL spot , but if it's - // already taken by an existing chat (e.g. step 3's - // YouTube agent that's still on canvas when step 5 - // creates a new chat for the same browser), placeCard - // cascades to the nearest free cell instead of - // stacking on top. + // Use placeCard (collision-aware) instead of setCardPosition (blind setter). The "left of the browser" anchor is the IDEAL spot, but if it's already taken by an existing chat (e.g. step 3's YouTube agent that's still on canvas when step 5 creates a new chat for the same browser), placeCard cascades to the nearest free cell instead of stacking on top. dispatch(placeCard({ sessionId: realId, x: bc.x - DEFAULT_CARD_W - GRID_GAP * 12, diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 3f366c9f..d615c2c0 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -30,8 +30,7 @@ import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; -// Module-level so the missed-runs review pops exactly once per app launch, -// not again on every dashboard switch. +// Module-level so the missed-runs review pops exactly once per app launch, not again on every dashboard switch. let missedRunsCheckedThisSession = false; interface UseDashboardLifecycleArgs { @@ -66,10 +65,7 @@ export function useDashboardLifecycle({ restoredExpandedRef, }: UseDashboardLifecycleArgs) { const dispatch = useAppDispatch(); - // True once THIS dashboard open has refetched outputs. The orphan-prune below - // keys off this, not the sticky global outputsLoaded, so it never wipes a - // just-imported app card by judging it against a stale (previous-dashboard) - // apps list before the fresh fetch lands. + // True once THIS dashboard open has refetched outputs. The orphan-prune below keys off this, not the sticky global outputsLoaded, so it never wipes a just-imported app card by judging it against a stale (previous-dashboard) apps list before the fresh fetch lands. const [outputsRefetched, setOutputsRefetched] = useState(false); const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl); const pendingFocusAgentId = useAppSelector((state) => state.tempState.pendingFocusAgentId); @@ -77,9 +73,7 @@ export function useDashboardLifecycle({ const pendingFocusWorkflowId = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowId); const pendingFocusWorkflowsHub = useAppSelector((state) => state.dashboardLayout.pendingFocusWorkflowsHub); - // Once per app launch: if scheduled fires elapsed while we were closed, fetch - // them. The slice flips its toast flag on fulfilled, so a bottom-left nudge - // shows instead of a card popping unrequested; the user opens the card from it. + // Once per app launch: if scheduled fires elapsed while we were closed, fetch them. The slice flips its toast flag on fulfilled, so a bottom-left nudge shows instead of a card popping unrequested; the user opens the card from it. useEffect(() => { if (!isActive || missedRunsCheckedThisSession) return; missedRunsCheckedThisSession = true; @@ -105,33 +99,23 @@ export function useDashboardLifecycle({ restoredExpandedRef.current = false; setOutputsRefetched(false); dispatch(resetLayout()); - // CRITICAL path: these populate the cards the user expects to see - // on first paint. Don't defer. + // CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer. dispatch(fetchSessions({ dashboardId })); dispatch(fetchLayout({ dashboardId })); const cleanupBrowserHandler = initBrowserCommandHandler(); - // Global broadcasts (spawned browser cards) skip the replay log, so a - // socket gap loses them; a reconnect refetch is the only way they return. + // Global broadcasts (spawned browser cards) skip the replay log, so a socket gap loses them; a reconnect refetch is the only way they return. const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => { dispatch(fetchSessions({ dashboardId })); dispatch(fetchLayout({ dashboardId, isReconnect: true })); - // workflow:run/updated/deleted are global broadcasts that skip the replay - // log, so a socket gap drops them: refetch to heal stale "running" cards, - // ghost workflows, and missed run history on reconnect. + // workflow:run/updated/deleted are global broadcasts that skip the replay log, so a socket gap drops them: refetch to heal stale "running" cards, ghost workflows, and missed run history on reconnect. dispatch(fetchWorkflows(dashboardId)); dispatch(fetchAllRuns(200)); dispatch(fetchActiveRuns()); }); - // DEFERRABLE: history list (for the search palette) and outputs - // (for the apps panel) aren't on the first-paint path. Same for the - // dashboard WS connection (it carries cross-session events; opens - // ~100ms later costs nothing). Pushing these into the post-paint - // window measurably improves LCP because the initial render - // pipeline isn't competing with their thunks/network setup. + // DEFERRABLE: history list (for the search palette) and outputs (for the apps panel) aren't on the first-paint path. Same for the dashboard WS connection (it carries cross-session events; opens ~100ms later costs nothing). Pushing these into the post-paint window measurably improves LCP because the initial render pipeline isn't competing with their thunks/network setup. const loadDeferred = () => { dispatch(fetchHistory({ dashboardId })); - // Mark outputs fresh only after a SUCCESSFUL fetch, so the prune below - // judges view cards against this dashboard's real apps, not a stale list. + // Mark outputs fresh only after a SUCCESSFUL fetch, so the prune below judges view cards against this dashboard's real apps, not a stale list. dispatch(fetchOutputs()).then((res) => { if (fetchOutputs.fulfilled.match(res)) setOutputsRefetched(true); }); @@ -142,12 +126,7 @@ export function useDashboardLifecycle({ ? (window as any).requestIdleCallback(loadDeferred, { timeout: 2000 }) : window.setTimeout(loadDeferred, 200); - // Pre-warm Anthropic's prompt cache for sessions on this dashboard - // ~250ms after mount (debounced; AbortController cancels on - // dashboard switch). Fires a max_tokens=1 ping per session so the - // user's first real message hits a warm cache instead of paying - // cold-start TTFT. Cheap (~$0.0001/session) and non-blocking. Skips - // for non-Anthropic sessions server-side. + // Pre-warm Anthropic's prompt cache for sessions on this dashboard ~250ms after mount (debounced; AbortController cancels on dashboard switch). Fires a max_tokens=1 ping per session so the user's first real message hits a warm cache instead of paying cold-start TTFT. Cheap (~$0.0001/session) and non-blocking. Skips for non-Anthropic sessions server-side. const warmAbort = new AbortController(); const warmTimer = setTimeout(async () => { try { @@ -161,8 +140,7 @@ export function useDashboardLifecycle({ ); for (const s of dashSessions) { if (warmAbort.signal.aborted) break; - // Fire-and-forget , the endpoint always 200s and the side - // effect is invisible cache population. + // Fire-and-forget, the endpoint always 200s and the side effect is invisible cache population. fetch(`${API_BASE}/agents/sessions/${s.id}/warm-cache`, { method: 'POST', signal: warmAbort.signal, @@ -179,8 +157,7 @@ export function useDashboardLifecycle({ cleanupBrowserHandler(); unsubReconnect(); dashboardWs.disconnect(); - // Cancel any not-yet-fired idle work; the cleanup handler can't - // run partially if the dashboard switches before idle fired. + // Cancel any not-yet-fired idle work; the cleanup handler can't run partially if the dashboard switches before idle fired. if (typeof window !== 'undefined') { const cancelIdle = (window as any).cancelIdleCallback; if (cancelIdle && typeof idleHandle === 'number') cancelIdle(idleHandle); @@ -224,18 +201,7 @@ export function useDashboardLifecycle({ }, 350); }, [isActive, pendingFocusAgentId, layoutInitialized, dispatch, canvasActions, handleHighlightCard]); - // Auto-focus a newly created browser card. The reducer that handles - // addBrowserCard sets pendingFocusBrowserId to the new card's id; this - // effect picks it up, pans/zooms the canvas to center on it, briefly - // highlights it, then clears the signal. Mirrors the pendingFocusAgentId - // pattern above so link clicks (intercepted in AppShell) get the same - // auto-focus behavior as the "+ Browser" toolbar button. - // - // Uses zoom=0.8 (the same value handleCardClick uses for browser cards - // at line ~344) instead of letting fitToCards auto-derive a zoom from - // padding. Browser cards are large (1280x800), so the auto-derived zoom - // would land around ~58% which feels too far back; 0.8 matches the - // "click on a browser to focus" experience the user expects. + // Auto-focus a newly created browser card. The reducer that handles addBrowserCard sets pendingFocusBrowserId to the new card's id; this effect picks it up, pans/zooms the canvas to center on it, briefly highlights it, then clears the signal. Mirrors the pendingFocusAgentId pattern above so link clicks (intercepted in AppShell) get the same auto-focus behavior as the "+ Browser" toolbar button. Uses zoom=0.8 (the same value handleCardClick uses for browser cards at line ~344) instead of letting fitToCards auto-derive a zoom from padding. Browser cards are large (1280x800), so the auto-derived zoom would land around ~58% which feels too far back; 0.8 matches the "click on a browser to focus" experience the user expects. useEffect(() => { if (!isActive) return; if (!pendingFocusBrowserId || !layoutInitialized) return; @@ -313,13 +279,7 @@ export function useDashboardLifecycle({ dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds })); }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); - // Prune orphan view cards whose underlying output was deleted (e.g. via - // the Views page). Without this, the layout entry persists in the minimap - // and contentBounds even though DashboardViewCard renders nothing. Gated on - // outputsRefetched (THIS open's fresh fetch), NOT the sticky global - // outputsLoaded: on a freshly-imported dashboard the global flag is already - // true from a prior dashboard, so the old gate pruned the just-imported app - // card against a stale apps list and the debounced save persisted the wipe. + // Prune orphan view cards whose underlying output was deleted (e.g. via the Views page). Without this, the layout entry persists in the minimap and contentBounds even though DashboardViewCard renders nothing. Gated on outputsRefetched (THIS open's fresh fetch), NOT the sticky global outputsLoaded: on a freshly-imported dashboard the global flag is already true from a prior dashboard, so the old gate pruned the just-imported app card against a stale apps list and the debounced save persisted the wipe. useEffect(() => { if (!layoutInitialized || !outputsRefetched) return; for (const outputId of Object.keys(viewCards)) { @@ -327,16 +287,7 @@ export function useDashboardLifecycle({ } }, [layoutInitialized, outputsRefetched, viewCards, outputs, dispatch]); - // On first load after outputs settle, snapshot every existing Output id as - // "already accounted for." Any output that ARRIVES later (typically the - // agent:output_upserted WS broadcast the backend fires the instant a - // view-builder session is seeded, at session start) whose session_id points - // at a view-builder chat on this dashboard gets a view card dropped on the - // canvas right away. Per-mount tracked so a manual close after auto-open - // stays closed. Prior approach keyed off a pending-set populated inside - // launchAndSendFirstMessage.then(): the WS upsert won the race and the - // effect saw an empty set, so the card didn't pop until the session-end - // meta-sync re-broadcast. + // On first load after outputs settle, snapshot every existing Output id as "already accounted for." Any output that ARRIVES later (typically the agent:output_upserted WS broadcast the backend fires the instant a view-builder session is seeded, at session start) whose session_id points at a view-builder chat on this dashboard gets a view card dropped on the canvas right away. Per-mount tracked so a manual close after auto-open stays closed. Prior approach keyed off a pending-set populated inside launchAndSendFirstMessage.then(): the WS upsert won the race and the effect saw an empty set, so the card didn't pop until the session-end meta-sync re-broadcast. const autoOpenedOutputsRef = useRef>(new Set()); const outputsSnapshottedRef = useRef(false); useEffect(() => { diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts index 6e9e321e..86328b59 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts @@ -71,9 +71,7 @@ export function useSiblingRestack({ cursor += h + GRID_GAP * 2; } } - // measuredHeightsTick in deps ensures we re-run once ResizeObserver reports - // the new height after a collapse (avoids stale-height no-ops) - // eslint-disable-next-line react-hooks/exhaustive-deps + // measuredHeightsTick in deps ensures we re-run once ResizeObserver reports the new height after a collapse (avoids stale-height no-ops) eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, expandedSessionIds, glowingAgentCards, cards, dispatch, measuredHeightsTick]); useEffect(() => { diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSubAgentLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSubAgentLifecycle.ts index c16ed8bf..61b3dc4d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSubAgentLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSubAgentLifecycle.ts @@ -90,11 +90,7 @@ export function useSubAgentLifecycle({ y: targetY, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H, - // Pass the current expanded-session set so placeCard's - // collision check uses real visual heights (expanded cards - // render ~620px tall instead of their stored collapsed - // height). Without this, sub-agents spawn into space the - // parent card visually occupies. + // Pass the current expanded-session set so placeCard's collision check uses real visual heights (expanded cards render ~620px tall instead of their stored collapsed height). Without this, sub-agents spawn into space the parent card visually occupies. expandedSessionIds, })); dispatch(expandSession(sub.id)); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts index e1b755f5..76679fd9 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -17,9 +17,7 @@ interface Args { spawnOriginsRef: RefObject>; } -// First-run welcome chat. NOT auto-created: the onboarding cursor clicks the New Agent button, -// which calls handleNewAgent -> createWelcomeDraft, so the chat is clicked into existence. The -// user clicking New Agent by hand spawns the same thing (fail-safe). Returns the gate + creator. +// First-run welcome chat. NOT auto-created: the onboarding cursor clicks the New Agent button, which calls handleNewAgent -> createWelcomeDraft, so the chat is clicked into existence. The user clicking New Agent by hand spawns the same thing (fail-safe). Returns the gate + creator. export function useWelcomeDraft({ dashboardId, canvasEmpty, expandedSessionIds, viewportRef, canvasStateRef, spawnOriginsRef, }: Args): { welcomeEligible: boolean; createWelcomeDraft: () => void } { @@ -36,8 +34,7 @@ export function useWelcomeDraft({ const createWelcomeDraft = useCallback(() => { try { - // No seeded message: the greeting + chips render (and animate) inside the welcome chat, - // so nothing here can ever reach the backend. + // No seeded message: the greeting + chips render (and animate) inside the welcome chat, so nothing here can ever reach the backend. const action = dispatch( createDraftSession({ welcome: true, model, mode: 'agent', dashboardId, setActive: true }), ); @@ -59,8 +56,7 @@ export function useWelcomeDraft({ height: EXPANDED_CARD_MIN_H, expandedSessionIds, })); - // placeCard grid-snaps + dodges collisions; the welcome chat is the only thing on a - // fresh dashboard, so pin it to the EXACT viewport center instead of a grid cell. + // placeCard grid-snaps + dodges collisions; the welcome chat is the only thing on a fresh dashboard, so pin it to the EXACT viewport center instead of a grid cell. dispatch(setCardPosition({ sessionId: draftId, x, y })); } dispatch(expandSession(draftId)); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index b26102a9..3740ff74 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -23,9 +23,7 @@ import { useAgentSpawn } from '../lifecycle/useAgentSpawn'; import { useDashboardCardActions } from '../lifecycle/useDashboardCardActions'; import { useDashboardInteractions } from '../interaction/useDashboardInteractions'; -// Composition root for the dashboard. Wires every dashboard hook together -// and returns exactly the prop bag DashboardCanvas renders. Kept out of -// Dashboard.tsx so the component file stays a thin shell. +// Composition root for the dashboard. Wires every dashboard hook together and returns exactly the prop bag DashboardCanvas renders. Kept out of Dashboard.tsx so the component file stays a thin shell. export function useDashboardController(dashboardId: string, isActive: boolean) { const c = useClaudeTokens(); const elementSelectionCtx = useElementSelection(); @@ -38,14 +36,10 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats, autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards, } = useDashboardSelectors(dashboardId); - // sessions is the top-level dict; useMemo on its identity so sessionList - // is stable when sessions hasn't actually changed (RTK only swaps the dict - // ref when one of its values changes, so this is the right granularity). + // sessions is the top-level dict; useMemo on its identity so sessionList is stable when sessions hasn't actually changed (RTK only swaps the dict ref when one of its values changes, so this is the right granularity). const sessionList = useMemo(() => Object.values(sessions), [sessions]); - // Run Monitor card geometry + its tether label ("Watching" live, "Viewing" done). - // Only "active" while its workflow still exists; otherwise the card is gone and - // the tether must not dangle (e.g. the workflow was trashed while watching). + // Run Monitor card geometry + its tether label ("Watching" live, "Viewing" done). Only "active" while its workflow still exists; otherwise the card is gone and the tether must not dangle (e.g. the workflow was trashed while watching). const workflowsMonitorIdRaw = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId); const monitorActive = !!workflowsMonitorIdRaw && !!workflowItems[workflowsMonitorIdRaw]; const workflowsMonitorId = monitorActive ? workflowsMonitorIdRaw : null; @@ -88,7 +82,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }); canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom }; - // Stable getter , AgentCards read pan/zoom on demand during drag math. + // Stable getter, AgentCards read pan/zoom on demand during drag math. const getCanvasState = useCallback(() => canvasStateRef.current, []); const { @@ -147,8 +141,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { restoredExpandedRef, }); - // First-run: the onboarding cursor clicks New Agent -> handleNewAgent -> createWelcomeDraft, - // spawning the welcome chat. A manual New Agent click does the same when eligible. + // First-run: the onboarding cursor clicks New Agent -> handleNewAgent -> createWelcomeDraft, spawning the welcome chat. A manual New Agent click does the same when eligible. const { welcomeEligible, createWelcomeDraft } = useWelcomeDraft({ dashboardId, canvasEmpty, @@ -191,10 +184,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { setSearchPaletteOpen, }); - // Starter-prompt click: opens the composer with the prompt typed in (translucent, - // unsent), so the user reviews and hits send. A Build starter also passes the - // App Builder mode ('view-builder') so it builds in-place on the dashboard, no - // context switch to the Apps page. Both cleared when the composer closes. + // Starter-prompt click: opens the composer with the prompt typed in (translucent, unsent), so the user reviews and hits send. A Build starter also passes the App Builder mode ('view-builder') so it builds in-place on the dashboard, no context switch to the Apps page. Both cleared when the composer closes. const [toolbarPrefill, setToolbarPrefill] = useState(undefined); const [toolbarPrefillMode, setToolbarPrefillMode] = useState(undefined); const handleStarter = useCallback((prompt: string, mode?: string) => { diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index efa0ae93..1b59427a 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -71,8 +71,7 @@ export function useDashboardSelection( const deselectAll = useCallback(() => setSelectedIds(new Map()), []); - // Cmd/Ctrl+A: select every card on the canvas so the user can wipe the - // board in one keystroke. Mirrors the per-type id keys the marquee uses. + // Cmd/Ctrl+A: select every card on the canvas so the user can wipe the board in one keystroke. Mirrors the per-type id keys the marquee uses. const selectAll = useCallback(() => { const next = new Map(); for (const card of Object.values(cards)) next.set(card.session_id, 'agent'); @@ -282,14 +281,7 @@ export function useDashboardSelection( return () => window.removeEventListener('keydown', onKeyDown); }, [deselectAll]); - // Inject (once) a global CSS rule that makes browser webviews and iframes - // transparent to mouse events while a marquee drag is active. Without this, - // the Electron hit-tests the cursor at the OS level , when the - // cursor lands on an interactable element inside the browser (button, - // link, text), the webview steals the cursor and the marquee drag visually - // freezes until the cursor escapes. Setting `pointer-events: none` makes - // the cursor pass straight through, so the dashboard's mousemove handler - // continues to fire and the marquee keeps growing smoothly. + // Inject (once) a global CSS rule that makes browser webviews and iframes transparent to mouse events while a marquee drag is active. Without this, the Electron hit-tests the cursor at the OS level, when the cursor lands on an interactable element inside the browser (button, link, text), the webview steals the cursor and the marquee drag visually freezes until the cursor escapes. Setting `pointer-events: none` makes the cursor pass straight through, so the dashboard's mousemove handler continues to fire and the marquee keeps growing smoothly. useEffect(() => { const id = 'dashboard-marquee-style'; if (document.getElementById(id)) return; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts index 7114eb7d..740d579e 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -1,8 +1,7 @@ import { useMemo } from 'react'; import { useAppSelector } from '@/shared/hooks'; -// All of the dashboard's Redux reads in one place. Keeps Dashboard.tsx a -// thin composition layer instead of a 25-line selector wall. +// All of the dashboard's Redux reads in one place. Keeps Dashboard.tsx a thin composition layer instead of a 25-line selector wall. export function useDashboardSelectors(dashboardId: string) { const dashboardName = useAppSelector((state) => dashboardId ? state.dashboards.items[dashboardId]?.name : undefined, @@ -12,11 +11,7 @@ export function useDashboardSelectors(dashboardId: string) { const cards = useAppSelector((state) => state.dashboardLayout.cards); const viewCards = useAppSelector((state) => state.dashboardLayout.viewCards); const allBrowserCards = useAppSelector((state) => state.dashboardLayout.browserCards); - // Browser cards live in a single global dict (no per-dashboard nesting) so - // a card spawned on dashboard A used to leak into dashboard B if the user - // switched mid-spawn. Filter here so every downstream consumer (render, - // bounds, layout save, keyboard nav) sees only this dashboard's cards. - // Legacy cards without dashboard_id fall through , next save tags them. + // Browser cards live in a single global dict (no per-dashboard nesting) so a card spawned on dashboard A used to leak into dashboard B if the user switched mid-spawn. Filter here so every downstream consumer (render, bounds, layout save, keyboard nav) sees only this dashboard's cards. Legacy cards without dashboard_id fall through, next save tags them. const browserCards = useMemo(() => { const out: typeof allBrowserCards = {}; for (const [id, bc] of Object.entries(allBrowserCards)) { diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts index 96eb55a3..39cf39f8 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts @@ -9,8 +9,7 @@ import { captureDashboardThumbnail } from '../../geometry/captureDashboardThumbn // Settle window after a card is added/removed before snapshotting, so the new card has a beat to render. const DASHBOARD_CAPTURE_DELAY_MS = 1200; -// Sorted set of every card id on the canvas. Changes on add/remove (not on move), -// so we can tell whether the dashboard's contents differ from the last screenshot. +// Sorted set of every card id on the canvas. Changes on add/remove (not on move), so we can tell whether the dashboard's contents differ from the last screenshot. function dashboardSignature(s: { cards: Record; viewCards: Record; @@ -40,10 +39,7 @@ export function useDashboardThumbnail({ viewportRef, contentRef, }: UseDashboardThumbnailArgs) { - // Screenshot the dashboard's contents for its card preview. Native Electron capturePage - // (no DOM mutation, no flash). We snapshot while the dashboard is visible whenever its card - // set changes and dispatch the update in-place, so the sidebar reorders as soon as the - // change settles rather than waiting for the user to navigate away. + // Screenshot the dashboard's contents for its card preview. Native Electron capturePage (no DOM mutation, no flash). We snapshot while the dashboard is visible whenever its card set changes and dispatch the update in-place, so the sidebar reorders as soon as the change settles rather than waiting for the user to navigate away. const currentSignature = useAppSelector((state) => dashboardSignature(state.dashboardLayout), ); @@ -73,11 +69,7 @@ export function useDashboardThumbnail({ } return; } - // Capturing the dashboard composites live webview pixels; doing it while a - // browser webview is mid-navigation OR an agent is actively driving it (its GPU - // surface recycling) crashes the renderer (SharedImage 'non-existent mailbox' -> - // V8 ToLocalChecked). Wait for it to go quiet; after a few tries, skip this round - // and keep the old preview rather than risk the crash. + // Capturing the dashboard composites live webview pixels; doing it while a browser webview is mid-navigation OR an agent is actively driving it (its GPU surface recycling) crashes the renderer (SharedImage 'non-existent mailbox' -> V8 ToLocalChecked). Wait for it to go quiet; after a few tries, skip this round and keep the old preview rather than risk the crash. if (anyWebviewLoading() || isAnyBrowserBusy()) { if (captureRetriesRef.current < 6) { captureRetriesRef.current += 1; @@ -103,8 +95,7 @@ export function useDashboardThumbnail({ .catch(() => {}); }, [dashboardId, viewportRef, contentRef]); - // While visible, (re)snapshot a beat after the card set changes. If it already matches the - // saved shot (or was reverted back to it), cancel any pending capture instead of committing stale pixels. + // While visible, (re)snapshot a beat after the card set changes. If it already matches the saved shot (or was reverted back to it), cancel any pending capture instead of committing stale pixels. useEffect(() => { if (!isActive || !dashboardId || !layoutInitialized) return; if (currentSignature === lastSavedSignatureRef.current) { diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardUiState.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardUiState.ts index a5fc709a..95859a80 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardUiState.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardUiState.ts @@ -5,10 +5,7 @@ import type { useDashboardSelection } from './useDashboardSelection'; type Selection = ReturnType; type SpawnOrigin = { x: number; y: number; type?: 'branch' }; -// Bundles the dashboard's purely-local UI bookkeeping (highlight pulse, -// auto-focus, pending-select, measured heights, reveal tracking) so -// Dashboard.tsx stays a thin composition layer. selection + cards come in -// from the parent because the pending-select effect needs both. +// Bundles the dashboard's purely-local UI bookkeeping (highlight pulse, auto-focus, pending-select, measured heights, reveal tracking) so Dashboard.tsx stays a thin composition layer. selection + cards come in from the parent because the pending-select effect needs both. export function useDashboardUiState(selection: Selection, cards: Record) { const toolbarRef = useRef(null); @@ -20,8 +17,7 @@ export function useDashboardUiState(selection: Selection, cards: Record(null); const [focusedCardId, setFocusedCardId] = useState(null); const [newAgentBounce, setNewAgentBounce] = useState(false); - // Cleanup any leftover walkthrough localStorage from v1 , the v2 panel - // ignores it but it would otherwise hang around forever. + // Cleanup any leftover walkthrough localStorage from v1, the v2 panel ignores it but it would otherwise hang around forever. useEffect(() => { try { localStorage.removeItem('openswarm_walkthrough_pending'); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts b/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts index d3f1812c..0411f427 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useLayoutSave.ts @@ -24,10 +24,7 @@ interface UseLayoutSaveArgs { captureNow: () => void; } -// Debounced layout persistence. The buffered pendingSaveRef + the unmount -// flush live together here, and this hook tears down exactly when -// DashboardInner does, so the launchAndSendFirstMessage-vs-unmount race -// keeps the same cadence it had inline. +// Debounced layout persistence. The buffered pendingSaveRef + the unmount flush live together here, and this hook tears down exactly when DashboardInner does, so the launchAndSendFirstMessage-vs-unmount race keeps the same cadence it had inline. export function useLayoutSave({ isActive, layoutInitialized, diff --git a/frontend/src/app/pages/DashboardSelection/DashboardSelection.tsx b/frontend/src/app/pages/DashboardSelection/DashboardSelection.tsx index 5085c4a8..b3898b6c 100644 --- a/frontend/src/app/pages/DashboardSelection/DashboardSelection.tsx +++ b/frontend/src/app/pages/DashboardSelection/DashboardSelection.tsx @@ -255,8 +255,7 @@ const DashboardSelection: React.FC = () => { { grouped[prov] = models.map((m) => ({ value: m.value, label: m.label })); for (const m of models) flat.push({ value: m.value, label: m.label, provider: prov }); } - // Guarantee the currently-selected default is always a valid option, even if - // the live list doesn't carry it (custom/OpenRouter value, or a stored model - // not in the current registry). Without this the dropdown gets an MUI - // "out-of-range value" warning and renders blank. + // Guarantee the currently-selected default is always a valid option, even if the live list doesn't carry it (custom/OpenRouter value, or a stored model not in the current registry). Without this the dropdown gets an MUI "out-of-range value" warning and renders blank. const sel = settings.default_model; if (sel && !flat.some((m) => m.value === sel)) { const other = 'Other'; @@ -134,9 +130,7 @@ const Settings: React.FC = () => { lastOpenTab = activeTab; }, [activeTab]); - // Sync form on modal open + first load only; including `settings` in deps wipes in-flight edits on background fetches (issue #25). - // baseline = the snapshot the user started editing from, so we can tell user edits - // apart from fields the backend changed underneath us (OAuth connects, free-trial mints). + // Sync form on modal open + first load only; including `settings` in deps wipes in-flight edits on background fetches (issue #25). baseline = the snapshot the user started editing from, so we can tell user edits apart from fields the backend changed underneath us (OAuth connects, free-trial mints). const baselineRef = useRef(settings); useEffect(() => { if (open && loaded) { @@ -146,21 +140,18 @@ const Settings: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, loaded]); - // Apply-on-change (System Settings style): edits save themselves after a short - // debounce, so text fields settle between keystrokes and toggles feel instant. + // Apply-on-change (System Settings style): edits save themselves after a short debounce, so text fields settle between keystrokes and toggles feel instant. const saveTimer = useRef | null>(null); const inFlight = useRef(false); - // Only the fields the user touched ride on top of the LATEST settings; submitting the - // whole stale form would clobber background updates and ping-pong with server-owned fields. + // Only the fields the user touched ride on top of the LATEST settings; submitting the whole stale form would clobber background updates and ping-pong with server-owned fields. const buildSubmit = useCallback((): { touched: string[]; patch: Partial } | null => { const base = baselineRef.current as unknown as Record; const f = form as unknown as Record; const touched = Array.from(new Set([...Object.keys(base), ...Object.keys(f)])) .filter((k) => JSON.stringify(f[k]) !== JSON.stringify(base[k])); if (touched.length === 0) return null; - // Send ONLY what the user changed; the server merges it onto fresh state, so - // we never re-send (and clobber) a field something else updated underneath us. + // Send ONLY what the user changed; the server merges it onto fresh state, so we never re-send (and clobber) a field something else updated underneath us. const patch: Record = {}; for (const k of touched) patch[k] = f[k]; return { touched, patch: patch as Partial }; @@ -177,8 +168,7 @@ const Settings: React.FC = () => { if (!buildSubmit()) return; if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = setTimeout(async () => { - // A save already in flight will update `settings` when it lands, re-running - // this effect to pick up whatever is still unsaved. + // A save already in flight will update `settings` when it lands, re-running this effect to pick up whatever is still unsaved. if (inFlight.current) return; const payload = buildSubmit(); if (!payload) return; diff --git a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx index 8c0c7335..0ee8b13f 100644 --- a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx +++ b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx @@ -10,10 +10,7 @@ import type { SettingsStyles } from '../settingsStyles'; const ERASE_WORD = 'ERASE'; -// The iOS Reset menu, two actions only: "Reset All Settings" (preferences back to -// defaults, your stuff + sign-in stay) and "Erase All Content and Settings" (factory -// wipe + relaunch). Flat rows, not a boxed "danger zone": red lives only on the -// destructive label, and the real friction is the typed-confirm in the dialog. +// The iOS Reset menu, two actions only: "Reset All Settings" (preferences back to defaults, your stuff + sign-in stay) and "Erase All Content and Settings" (factory wipe + relaunch). Flat rows, not a boxed "danger zone": red lives only on the destructive label, and the real friction is the typed-confirm in the dialog. const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => { const c = useClaudeTokens(); const { sectionSx, labelSx, descSx } = styles; @@ -38,8 +35,7 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => try { const res = await fetch(`${API_BASE}/settings/reset-to-defaults`, { method: 'POST' }); if (!res.ok) throw new Error(String(res.status)); - // Reload so every slice + local component state re-syncs from the now-default - // backend; no stale flag can survive a full renderer reload. + // Reload so every slice + local component state re-syncs from the now-default backend; no stale flag can survive a full renderer reload. window.location.reload(); } catch { setBusy(false); diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx index 9b2ea089..42b4af45 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx @@ -24,9 +24,7 @@ const GeneralAdvanced: React.FC<{ const appVersion = useAppSelector((s) => s.update.appVersion); const { sectionSx, rowSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = styles; - // Provenance: the exact commit this build was cut from. Surfaced so a support - // screenshot of Settings is enough to identify the shipped code. Empty in dev - // / web (no Electron bridge or unknown sha), in which case we hide the row. + // Provenance: the exact commit this build was cut from. Surfaced so a support screenshot of Settings is enough to identify the shipped code. Empty in dev / web (no Electron bridge or unknown sha), in which case we hide the row. const [buildLabel, setBuildLabel] = React.useState(null); React.useEffect(() => { const api = (window as { openswarm?: { getBuildInfo?: () => Promise<{ shortSha: string; channel: string }> } }).openswarm; @@ -118,9 +116,7 @@ const GeneralAdvanced: React.FC<{ dispatch(resetTour()); dispatch(closeSettingsModal()); onboardingBus.emit('settings:closed'); - // In-place reset can't re-arm the welcome cursor's once-per-mount - // guard, so the tour never re-fired without a reload; reload from the - // now-cleared storage is the reliable restart (matches the workaround). + // In-place reset can't re-arm the welcome cursor's once-per-mount guard, so the tour never re-fired without a reload; reload from the now-cleared storage is the reliable restart (matches the workaround). window.location.reload(); }} sx={{ diff --git a/frontend/src/app/pages/Settings/sections/settingSelect.ts b/frontend/src/app/pages/Settings/sections/settingSelect.ts index b17e4a4e..6453ef52 100644 --- a/frontend/src/app/pages/Settings/sections/settingSelect.ts +++ b/frontend/src/app/pages/Settings/sections/settingSelect.ts @@ -1,7 +1,4 @@ -// The data-select-* handle that makes a Settings row pointable by the chat -// element-selector. One source for it, spread onto a row's Box, so a row's label -// and its selection metadata can't drift apart (same args) and every section -// opts in the same way instead of copy-pasting the JSON.stringify. +// The data-select-* handle that makes a Settings row pointable by the chat element-selector. One source for it, spread onto a row's Box, so a row's label and its selection metadata can't drift apart (same args) and every section opts in the same way instead of copy-pasting the JSON.stringify. export function settingSelectAttrs(field: string, name: string, category: string, description?: string) { return { 'data-select-type': 'settings-option', diff --git a/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts b/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts index 25671357..a5fcf6e4 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts +++ b/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts @@ -197,11 +197,7 @@ function runAuthCodeFlow(ctx: ConnectCtx) { try { body = await r.json(); } catch {} succeeded = r.ok && !!body?.success; } catch {} - // /providers lags /exchange by a few seconds, so confirm an actually-active - // node within a short window instead of trusting bare HTTP success. A handoff - // that returns success but never lands a usable node must NOT show "Connected" - // (that was the card-says-connected-but-every-run-fails bug); it resets so the - // user sees Connect/retry. + // /providers lags /exchange by a few seconds, so confirm an actually-active node within a short window instead of trusting bare HTTP success. A handoff that returns success but never lands a usable node must NOT show "Connected" (that was the card-says-connected-but-every-run-fails bug); it resets so the user sees Connect/retry. let confirmed = false; if (succeeded) { for (let i = 0; i < 6 && !confirmed; i++) { @@ -237,9 +233,7 @@ function runAuthCodeFlow(ctx: ConnectCtx) { }); } - // If the user comes back to openswarm without finishing OAuth (closed the browser, cancelled), - // 3s of sustained focus + no active connection means abandoned; clear Connecting so they can retry. - // A blur during the wait cancels, so brief tab-backs to check progress don't false-positive. + // If the user comes back to openswarm without finishing OAuth (closed the browser, cancelled), 3s of sustained focus + no active connection means abandoned; clear Connecting so they can retry. A blur during the wait cancels, so brief tab-backs to check progress don't false-positive. const onBlur = () => { if (resetTimer) { clearTimeout(resetTimer); resetTimer = null; } }; diff --git a/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx b/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx index f4dcde46..a82b57fb 100644 --- a/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx +++ b/frontend/src/app/pages/Settings/sections/usage/UsageStats.tsx @@ -120,8 +120,7 @@ const UsageStats: React.FC = () => { const isSubscription = stats.cost_source === '9router'; const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : ''; - // Quirky savings nudge (subscription users only): what their token usage would've cost at API - // rates, framed a little differently each day so it stays fun without nagging. + // Quirky savings nudge (subscription users only): what their token usage would've cost at API rates, framed a little differently each day so it stays fun without nagging. const savedAmt = stats.total_cost_usd || 0; const sessionsLabel = (stats.total_sessions || 0).toLocaleString(); const lattes = Math.max(1, Math.round(savedAmt / 5.75)); diff --git a/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx b/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx index 748674fd..d018ba60 100644 --- a/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx +++ b/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx @@ -28,9 +28,7 @@ interface Props { onInstalled: (name: string) => void; } -// The skills.sh wild registry is unvetted community code (skills can ship -// scripts). So this dialog never installs blind: picking a skill fetches a -// disclosure (files + scripts) the user confirms before anything lands on disk. +// The skills.sh wild registry is unvetted community code (skills can ship scripts). So this dialog never installs blind: picking a skill fetches a disclosure (files + scripts) the user confirms before anything lands on disk. const CommunitySkillsDialog: React.FC = ({ open, onClose, onInstalled }) => { const c = useClaudeTokens(); const [query, setQuery] = useState(''); @@ -41,8 +39,7 @@ const CommunitySkillsDialog: React.FC = ({ open, onClose, onInstalled }) const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const debounceRef = useRef | null>(null); - // Monotonic request tokens: a slow response from an earlier search/preview - // must not overwrite the state a newer one already set (out-of-order network). + // Monotonic request tokens: a slow response from an earlier search/preview must not overwrite the state a newer one already set (out-of-order network). const searchSeq = useRef(0); const previewSeq = useRef(0); diff --git a/frontend/src/app/pages/Views/HistoryPanel.tsx b/frontend/src/app/pages/Views/HistoryPanel.tsx index 127e43d2..50944728 100644 --- a/frontend/src/app/pages/Views/HistoryPanel.tsx +++ b/frontend/src/app/pages/Views/HistoryPanel.tsx @@ -76,8 +76,7 @@ const HistoryPanel: React.FC = ({ outputId, isAgentActive, saveLabel, onB window.setTimeout(() => { if (mountedRef.current) setStatus(null); }, 3500); }, []); - // alive-guarded so a slower fetch for a previous outputId (or after close) can't - // overwrite the list. Handlers bump reloadKey to refetch. + // alive-guarded so a slower fetch for a previous outputId (or after close) can't overwrite the list. Handlers bump reloadKey to refetch. useEffect(() => { let alive = true; setLoading(true); @@ -91,8 +90,7 @@ const HistoryPanel: React.FC = ({ outputId, isAgentActive, saveLabel, onB const handleSave = useCallback(async () => { setSaving(true); try { - // No reloadKey bump: captureOutputVersion.fulfilled bumps captureSignal, - // which already drives the refetch. Bumping both = a double fetch. + // No reloadKey bump: captureOutputVersion.fulfilled bumps captureSignal, which already drives the refetch. Bumping both = a double fetch. await dispatch(captureOutputVersion({ id: outputId, source: 'manual', label: saveLabel || '' })).unwrap(); flash('ok', 'Saved this version.'); } catch { diff --git a/frontend/src/app/pages/Views/ViewCard.tsx b/frontend/src/app/pages/Views/ViewCard.tsx index ba7f2815..327c9161 100644 --- a/frontend/src/app/pages/Views/ViewCard.tsx +++ b/frontend/src/app/pages/Views/ViewCard.tsx @@ -48,8 +48,7 @@ const ViewCard: React.FC = ({ output, onClick, onDelete, onRun, onHistory ): string { const ignore = new Set(['meta.json', 'schema.json', 'SKILL.md']); return Object.keys(files) @@ -339,9 +336,7 @@ const ViewEditor: React.FC = ({ output }) => { // Gates the preview webview behind PREVIEW_MOUNT_DEBOUNCE_MS so fast-switched-past apps never mount one. const [previewSettled, setPreviewSettled] = useState(false); - // Thumbnail capture state. lastCaptured starts at the current render key when a - // thumbnail already exists, so merely opening an app doesn't re-shoot (and re-sort) it; - // null when there's no thumbnail yet, so the first paint backfills one. + // Thumbnail capture state. lastCaptured starts at the current render key when a thumbnail already exists, so merely opening an app doesn't re-shoot (and re-sort) it; null when there's no thumbnail yet, so the first paint backfills one. const filesRef = useRef(files); filesRef.current = files; const isAgentActiveRef = useRef(false); @@ -378,10 +373,7 @@ const ViewEditor: React.FC = ({ output }) => { document.body.style.userSelect = ''; }, []); - // Seed from the existing session id so a warm reopen resolves effectiveSessionId - // on the FIRST render (no "Initializing agent..." blank frame while the mount - // effect re-derives it). Cold opens still read null until fetchSession lands, - // because the selector requires the session to actually be in the store. + // Seed from the existing session id so a warm reopen resolves effectiveSessionId on the FIRST render (no "Initializing agent..." blank frame while the mount effect re-derives it). Cold opens still read null until fetchSession lands, because the selector requires the session to actually be in the store. const [initialDraftId, setInitialDraftId] = useState(output?.session_id ?? null); const [workspacePath, setWorkspacePath] = useState(null); // Reuse the Output's workspace_id across remounts so we don't orphan agent edits or chat history. @@ -395,13 +387,7 @@ const ViewEditor: React.FC = ({ output }) => { const modelsByProvider = useAppSelector((s) => s.models.byProvider); const modelsLoaded = useAppSelector((s) => s.models.loaded); - // Spam-clicking sidebar apps used to fire EVERY app's seed + agent-init + runtime boot on - // each click, flooding the backend: the "Initializing agent..." stall (the landed app's init - // can't get through the backlog) and, under enough load, an orderly self-quit. Gate all the - // heavy per-app work behind sustained focus, it only runs if you STAY on the app ~800ms. A - // brand-new app (no output id yet) is always a deliberate open, so it settles immediately, - // keeping the onboarding /apps/new flow snappy. A warm reopen still renders its chat instantly - // from initialDraftId above, this only delays the background reattach/seed for click-throughs. + // Spam-clicking sidebar apps used to fire EVERY app's seed + agent-init + runtime boot on each click, flooding the backend: the "Initializing agent..." stall (the landed app's init can't get through the backlog) and, under enough load, an orderly self-quit. Gate all the heavy per-app work behind sustained focus, it only runs if you STAY on the app ~800ms. A brand-new app (no output id yet) is always a deliberate open, so it settles immediately, keeping the onboarding /apps/new flow snappy. A warm reopen still renders its chat instantly from initialDraftId above, this only delays the background reattach/seed for click-throughs. const isNewApp = !output?.id; const [focusSettled, setFocusSettled] = useState(isNewApp); useEffect(() => { @@ -441,12 +427,7 @@ const ViewEditor: React.FC = ({ output }) => { (async () => { // Reattach: Output has an existing session + workspace; skip seeding/draft so we don't clobber agent state. if (output?.session_id && output?.workspace_id) { - // Mount the chat NOW so a warm conversation renders instantly; holding it - // behind the verification round-trips blanked the chat for seconds on - // every reopen. The fetch is load-bearing on cold opens: effectiveSessionId - // resolves only once the session is IN the store, and AgentChat (the other - // hydrator) can't mount until then. The stale-id guard below still runs in - // the background and swaps in a fresh draft on the rare 404. + // Mount the chat NOW so a warm conversation renders instantly; holding it behind the verification round-trips blanked the chat for seconds on every reopen. The fetch is load-bearing on cold opens: effectiveSessionId resolves only once the session is IN the store, and AgentChat (the other hydrator) can't mount until then. The stale-id guard below still runs in the background and swaps in a fresh draft on the rare 404. dispatch(fetchSession(output.session_id)); setInitialDraftId(output.session_id); @@ -719,17 +700,12 @@ const ViewEditor: React.FC = ({ output }) => { useEffect(() => { if (prevAgentActive.current && !isAgentActive) { if (workspaceId) setTimeout(pollWorkspace, 500); - // A change just finished: quietly save a version so the user can go back to - // it. Delayed so files + a fresh preview settle first. Fire-and-forget and - // error-swallowed; saving history must never disrupt the editor, and the - // backend dedupes so a run that changed nothing won't pile up a junk version. + // A change just finished: quietly save a version so the user can go back to it. Delayed so files + a fresh preview settle first. Fire-and-forget and error-swallowed; saving history must never disrupt the editor, and the backend dedupes so a run that changed nothing won't pile up a junk version. const eid = output?.id ?? createdIdRef.current; if (eid) { const label = lastUserPromptRef.current.slice(0, 140); window.setTimeout(async () => { - // A new run started inside the settle window: skip, or we'd snapshot a - // half-written workspace under the previous run's label. Its own - // completion will capture the settled state. + // A new run started inside the settle window: skip, or we'd snapshot a half-written workspace under the previous run's label. Its own completion will capture the settled state. if (isAgentActiveRef.current) return; let thumbnail: string | null = null; try { thumbnail = (await previewRef.current?.capture()) ?? null; } catch { /* preview not mounted */ } @@ -810,10 +786,7 @@ const ViewEditor: React.FC = ({ output }) => { }; }; - // Snapshot the live preview once content settles. Guards: skip mid-agent-run, skip - // if nothing visual changed since the last shot, skip until the app row exists. - // capture() returns null when the preview isn't mounted/ready, so a miss leaves - // lastCaptured untouched and a later paint can still backfill the thumbnail. + // Snapshot the live preview once content settles. Guards: skip mid-agent-run, skip if nothing visual changed since the last shot, skip until the app row exists. capture() returns null when the preview isn't mounted/ready, so a miss leaves lastCaptured untouched and a later paint can still backfill the thumbnail. const captureAppThumbnail = useCallback(() => { if (isAgentActiveRef.current) return; const eid = output?.id ?? createdIdRef.current; @@ -978,10 +951,7 @@ const ViewEditor: React.FC = ({ output }) => { }; }, [workspaceId, runtimeShouldRun, appendTerminalLine]); - // One-shot trigger on the same sustained-focus gate as the seed: flips runtimeShouldRun true - // once this app is the focus pick (focusSettled) and you're on Preview/Terminal, never flips - // back. workspaceId is already downstream of the focus-gated seed, so this just keeps the - // intent explicit, an app you click past never boots a vite runtime, only one you settle on. + // One-shot trigger on the same sustained-focus gate as the seed: flips runtimeShouldRun true once this app is the focus pick (focusSettled) and you're on Preview/Terminal, never flips back. workspaceId is already downstream of the focus-gated seed, so this just keeps the intent explicit, an app you click past never boots a vite runtime, only one you settle on. useEffect(() => { if (!workspaceId || runtimeShouldRun || !focusSettled) return; const wantsRuntime = activeTab === TAB_PREVIEW || activeTab === TAB_TERMINAL; @@ -1000,8 +970,7 @@ const ViewEditor: React.FC = ({ output }) => { setIframePainted(false); }, [workspaceServeUrl]); - // Mount the preview only once this app has been the open one for a beat. The timer is cancelled on - // unmount, so blowing through apps faster than PREVIEW_MOUNT_DEBOUNCE_MS never spawns their renderers. + // Mount the preview only once this app has been the open one for a beat. The timer is cancelled on unmount, so blowing through apps faster than PREVIEW_MOUNT_DEBOUNCE_MS never spawns their renderers. useEffect(() => { const t = window.setTimeout(() => setPreviewSettled(true), PREVIEW_MOUNT_DEBOUNCE_MS); return () => window.clearTimeout(t); @@ -1128,9 +1097,7 @@ const ViewEditor: React.FC = ({ output }) => { }; }, [files, name, description]); - // Live-preview (Vite/HMR) mode patches the webview without a load event, so onContentLoad - // misses most agent edits. Re-arm capture when files settle or the agent goes idle; - // captureAppThumbnail debounces and skips mid-run, no-op, and not-yet-saved cases. + // Live-preview (Vite/HMR) mode patches the webview without a load event, so onContentLoad misses most agent edits. Re-arm capture when files settle or the agent goes idle; captureAppThumbnail debounces and skips mid-run, no-op, and not-yet-saved cases. useEffect(() => { if (!frontendUrl) return; captureAppThumbnail(); @@ -1191,12 +1158,7 @@ const ViewEditor: React.FC = ({ output }) => { position: 'relative', bgcolor: 'transparent', transition: 'background-color 0.15s', - // Draw the line on the CHAT side of the seam, never centered. The app - // preview is an Electron (its own compositor layer that floats - // above normal DOM and ignores z-index), so a centered line had its - // right half swallowed by the webview in the body but not in the - // header, the long-standing thick-at-top look. Anchoring left of the - // seam keeps the whole line over plain DOM, so it's uniform. + // Draw the line on the CHAT side of the seam, never centered. The app preview is an Electron (its own compositor layer that floats above normal DOM and ignores z-index), so a centered line had its right half swallowed by the webview in the body but not in the header, the long-standing thick-at-top look. Anchoring left of the seam keeps the whole line over plain DOM, so it's uniform. '&::after': { content: '""', position: 'absolute', diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index e38ca116..a62212ff 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -14,9 +14,7 @@ const isElectron = navigator.userAgent.includes('Electron'); const THUMB_WIDTH = 600; const THUMB_QUALITY = 0.7; -// NativeImage -> small JPEG data URL. We resize on the native image (cheap) then -// re-encode via canvas because NativeImage.toJPEG hands back a Node Buffer that -// the sandboxed renderer can't base64 on its own. +// NativeImage -> small JPEG data URL. We resize on the native image (cheap) then re-encode via canvas because NativeImage.toJPEG hands back a Node Buffer that the sandboxed renderer can't base64 on its own. async function nativeImageToJpegDataUrl(img: any, width: number, quality: number): Promise { const sized = typeof img.resize === 'function' ? img.resize({ width }) : img; const pngUrl: string = sized.toDataURL(); @@ -201,8 +199,7 @@ const ViewPreview = forwardRef(({ const wv = webviewRef.current; // Only the webview path is reliably snapshottable; iframe/dev or a hidden window (about:blank) returns null. if (!useWebview || !wv || windowHidden || typeof wv.capturePage !== 'function') return null; - // Never snapshot a guest that's detaching, crashed, or mid-navigation: capturePage on a - // WebContents being torn down can segfault the main process (the whole-app quit on fast app-switching). + // Never snapshot a guest that's detaching, crashed, or mid-navigation: capturePage on a WebContents being torn down can segfault the main process (the whole-app quit on fast app-switching). try { if (wv.isConnected === false) return null; if (typeof wv.isCrashed === 'function' && wv.isCrashed()) return null; @@ -225,9 +222,7 @@ const ViewPreview = forwardRef(({ } }, [srcdoc, useWebview]); - // Listen for preload IPC: console forwarding, canvas wheel forwarding - // (matches BrowserCard so apps share the same dashboard pan/zoom defaults), - // and the app-clicked notification that flips the card into interact mode. + // Listen for preload IPC: console forwarding, canvas wheel forwarding (matches BrowserCard so apps share the same dashboard pan/zoom defaults), and the app-clicked notification that flips the card into interact mode. useEffect(() => { if (!useWebview) return; const wv = webviewRef.current; @@ -281,8 +276,7 @@ const ViewPreview = forwardRef(({ }; }, [useWebview, onConsoleMessage, onAppClicked, iframeSrc]); - // Mirror `interactive` into a ref so the once-per-load did-finish-load - // listener can read the latest value when it pushes initial state. + // Mirror `interactive` into a ref so the once-per-load did-finish-load listener can read the latest value when it pushes initial state. const interactiveRef = useRef(interactive); interactiveRef.current = interactive; useEffect(() => { @@ -397,8 +391,7 @@ const ViewPreview = forwardRef(({ width: '100%', height: '100%', border: 'none', - // Best-effort: newer Chromium clips a 's own radius even though - // it ignores a parent's. Square in older builds, harmless either way. + // Best-effort: newer Chromium clips a 's own radius even though it ignores a parent's. Square in older builds, harmless either way. borderRadius: '12px', overflow: 'hidden', background: _hostBg, diff --git a/frontend/src/app/pages/Views/Views.tsx b/frontend/src/app/pages/Views/Views.tsx index 56c3ecbd..cecd60ff 100644 --- a/frontend/src/app/pages/Views/Views.tsx +++ b/frontend/src/app/pages/Views/Views.tsx @@ -32,8 +32,7 @@ const Views: React.FC = () => { const [editingOutput, setEditingOutput] = useState(null); const [runOutput, setRunOutput] = useState(null); const [historyOutput, setHistoryOutput] = useState(null); - // Branch closes the history modal, which would unmount the panel before its own - // flash renders; surface the confirmation at the grid level so it survives. + // Branch closes the history modal, which would unmount the panel before its own flash renders; surface the confirmation at the grid level so it survives. const [branchToast, setBranchToast] = useState(false); useEffect(() => { diff --git a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx index 9e8b1db0..0cd3d086 100644 --- a/frontend/src/app/pages/Workflows/MissedRunsToast.tsx +++ b/frontend/src/app/pages/Workflows/MissedRunsToast.tsx @@ -1,6 +1,4 @@ -// Bottom-left nudge shown on launch when scheduled runs elapsed while the app -// was closed. It stays put until the user acts (no auto-hide): Review opens the -// Workflows app (its Home surfaces the missed runs); the X dismisses it. +// Bottom-left nudge shown on launch when scheduled runs elapsed while the app was closed. It stays put until the user acts (no auto-hide): Review opens the Workflows app (its Home surfaces the missed runs); the X dismisses it. import React from 'react'; import Snackbar from '@mui/material/Snackbar'; diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 54f88d57..6c737ab6 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -21,14 +21,10 @@ interface Props { refDate?: Date; } -// Both compact (popover) and roomy (hub) show the full 24 hours scrollable; -// the user explicitly wants midnight visible at the top, not "9am" as the -// starting hour. The scroll container caps the visible window. +// Both compact (popover) and roomy (hub) show the full 24 hours scrollable; the user explicitly wants midnight visible at the top, not "9am" as the starting hour. The scroll container caps the visible window. const HOURS_24 = Array.from({ length: 24 }, (_, i) => i); -// At/above this many list rows (day headers + event rows), window the list so -// only near-viewport rows stay mounted. Below it, render whole; spacers aren't -// worth the churn on a short list. +// At/above this many list rows (day headers + event rows), window the list so only near-viewport rows stay mounted. Below it, render whole; spacers aren't worth the churn on a short list. const LIST_WINDOW_MIN_ROWS = 60; interface CalendarEvent { @@ -36,9 +32,7 @@ interface CalendarEvent { fire_at: string; } -// One flattened list row. Windowing unmounts at this granularity, so a dense -// single day no longer mounts all ~96 of its rows just for being near the -// viewport: only the rows actually in view (plus buffer) stay in the DOM. +// One flattened list row. Windowing unmounts at this granularity, so a dense single day no longer mounts all ~96 of its rows just for being near the viewport: only the rows actually in view (plus buffer) stay in the DOM. type ListRow = | { kind: 'header'; id: string; date: Date; isToday: boolean } | { kind: 'event'; id: string; ev: { workflow: Workflow; date: Date } } @@ -49,15 +43,13 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const dispatch = useAppDispatch(); const workflows = useAppSelector((s) => Object.values(s.workflows.items)); const allPaused = useAppSelector((s) => s.workflows.paused); - // Live clock for the "now" line; a snapshot would drift and refDate may be - // a navigated week, so it can't double as the current moment. + // Live clock for the "now" line; a snapshot would drift and refDate may be a navigated week, so it can't double as the current moment. const [now, setNow] = useState(() => new Date()); useEffect(() => { const id = setInterval(() => setNow(new Date()), 60_000); return () => clearInterval(id); }, []); - // Right-click menu: pinned position + the workflow whose pill was - // clicked. Same anchor pattern as MUI's menu examples. + // Right-click menu: pinned position + the workflow whose pill was clicked. Same anchor pattern as MUI's menu examples. const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null); const closeMenu = () => setCtxMenu(null); const onRunNow = () => { @@ -81,8 +73,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const onEdit = () => { if (!ctxMenu) return; dispatch(addWorkflowCard({ workflowId: ctxMenu.workflow.id })); - // Right-click "Edit" on a calendar entry opens the new Edit Agent - // chat view, matching the post-revamp design (Image #38). + // Right-click "Edit" on a calendar entry opens the new Edit Agent chat view, matching the post-revamp design (Image #38). dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit_agent' })); closeMenu(); }; @@ -105,9 +96,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD Delete ); - // refDate is recreated on every render unless the caller memoizes it. - // Pin the calendar to a day-precision key so occurrence fetches only - // change when the visible day, view, or schedule set changes. + // refDate is recreated on every render unless the caller memoizes it. Pin the calendar to a day-precision key so occurrence fetches only change when the visible day, view, or schedule set changes. const today = refDate || new Date(); const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; const compact = density === 'compact'; @@ -120,10 +109,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]); const [calendarEvents, setCalendarEvents] = useState([]); const [calendarFetchKey, setCalendarFetchKey] = useState(''); - // Key off only the fields that change which occurrences exist. Deliberately - // NOT updated_at: the scheduler bumps it every tick (recomputing next_run_at) - // and pushes a workflow:updated over the socket, which would churn this key - // and blank the calendar (the eventsByDay gate) until the next fetch lands. + // Key off only the fields that change which occurrences exist. Deliberately NOT updated_at: the scheduler bumps it every tick (recomputing next_run_at) and pushes a workflow:updated over the socket, which would churn this key and blank the calendar (the eventsByDay gate) until the next fetch lands. const workflowScheduleKey = workflows .map((w) => `${w.id}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.day_of_month ?? ''}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`) .sort() @@ -131,19 +117,11 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const fromIso = rangeStart.toISOString(); const toIso = rangeEndExclusive.toISOString(); const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`; - // The visible window alone decides whether shown events are even plausible. - // Gating on this (not the full request key) means a schedule edit refetches - // without blanking the calendar first: we keep the current events until the - // fresh ones land. Only a view/date change, where old events are for the - // wrong window, clears them. + // The visible window alone decides whether shown events are even plausible. Gating on this (not the full request key) means a schedule edit refetches without blanking the calendar first: we keep the current events until the fresh ones land. Only a view/date change, where old events are for the wrong window, clears them. const calendarWindowKey = `${view}:${fromIso}:${toIso}`; useEffect(() => { - // No AbortController: the global fetch interceptor (shared/config) dedupes - // GETs by URL onto ONE underlying request, so aborting on cleanup (which - // fires when this effect re-runs as workflows hydrate) rejects the shared - // request and the re-fired fetch with it, leaving the calendar empty on - // first load. The `cancelled` guard already stops stale state writes. + // No AbortController: the global fetch interceptor (shared/config) dedupes GETs by URL onto ONE underlying request, so aborting on cleanup (which fires when this effect re-runs as workflows hydrate) rejects the shared request and the re-fired fetch with it, leaving the calendar empty on first load. The `cancelled` guard already stops stale state writes. let cancelled = false; fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`) .then((res) => { @@ -186,11 +164,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey }; }, [calendarEvents, calendarFetchKey, calendarWindowKey, workflows, rangeStart, rangeEndExclusive]); - // List view can fan out to ~1300 rows for a dense schedule (every 15 min over - // 14 days). Flatten days into rows and window at the row level so off-screen - // rows unmount instead of weighing the whole app down. Computed up here (not - // in the List branch) so the windowing hook runs before the Week/Month early - // returns. + // List view can fan out to ~1300 rows for a dense schedule (every 15 min over 14 days). Flatten days into rows and window at the row level so off-screen rows unmount instead of weighing the whole app down. Computed up here (not in the List branch) so the windowing hook runs before the Week/Month early returns. const upcoming = useMemo(() => { const out: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = []; for (let i = 0; i < 14; i += 1) { @@ -242,10 +216,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD const HOURS = HOURS_24; const nowColIdx = days.findIndex((d) => sameDay(d, now)); const nowTopPx = (now.getHours() + now.getMinutes() / 60) * SLOT_H; - // Prefer the short zone name ("PDT", "EST", "JST") so the label - // reads in plain English instead of "GMT-7". formatToParts is wide- - // supported; if it ever fails we degrade silently rather than show - // a confusing fallback. + // Prefer the short zone name ("PDT", "EST", "JST") so the label reads in plain English instead of "GMT-7". formatToParts is wide- supported; if it ever fails we degrade silently rather than show a confusing fallback. const TZ_LABEL = (() => { try { const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date()); @@ -323,9 +294,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD if (!wid) return; const wf = workflows.find((w) => w.id === wid); if (!wf) return; - // Build the patched schedule: new hour, and for - // weekly schedules swap on_days to just the target - // weekday. Daily/monthly only get the new hour. + // Build the patched schedule: new hour, and for weekly schedules swap on_days to just the target weekday. Daily/monthly only get the new hour. const sched = { ...wf.schedule, hour } as typeof wf.schedule; if (sched.repeat_unit === 'week') sched.on_days = [targetWeekday]; dispatch(updateWorkflow({ @@ -425,13 +394,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD ); } - // Apple-Calendar-style list: each day is a stacked group with the date as a - // header and its events listed underneath, so a busy day stays readable top - // to bottom instead of crammed beside a date column. Today renders even with - // no events (shows a "No events today" placeholder) - // so the list doesn't feel empty for new users. Off-screen day groups - // unmount (useWindowedList) and leave a measured-height spacer behind, so a - // dense schedule stays light no matter how far down you scroll. + // Apple-Calendar-style list: each day is a stacked group with the date as a header and its events listed underneath, so a busy day stays readable top to bottom instead of crammed beside a date column. Today renders even with no events (shows a "No events today" placeholder) so the list doesn't feel empty for new users. Off-screen day groups unmount (useWindowedList) and leave a measured-height spacer behind, so a dense schedule stays light no matter how far down you scroll. const accent = c.accent.primary; const visibleRows = rows.slice(windowing.start, windowing.end); return ( @@ -505,8 +468,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD ); } -// Apple Calendar style event stack: tiny bars in the hour cell, followed by a -// text overflow affordance when the hour has more runs than fit. +// Apple Calendar style event stack: tiny bars in the hour cell, followed by a text overflow affordance when the hour has more runs than fit. function EventStack({ events, paused, now, maxVisible, onSelectWorkflow, eventFontSize, onContextWorkflow }: { events: { workflow: Workflow; date: Date }[]; paused?: boolean; @@ -600,9 +562,7 @@ function EventStack({ events, paused, now, maxVisible, onSelectWorkflow, eventFo ); } -// "+N more" on a packed month cell opens a scrollable popover listing every -// run that day, so a heavy day isn't a dead end. Past fires keep the hollow -// ring the cell rows use, for a consistent at-a-glance "already ran" read. +// "+N more" on a packed month cell opens a scrollable popover listing every run that day, so a heavy day isn't a dead end. Past fires keep the hollow ring the cell rows use, for a consistent at-a-glance "already ran" read. function MonthDayOverflow({ date, count, events, now, fontSize, onSelectWorkflow }: { date: Date; count: number; diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx index d29603fe..690cd96f 100644 --- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -56,8 +56,7 @@ export default function SchedulePopover({ }: Props) { const showSearch = chatHistoryOnly || mode === 'search'; const c = useClaudeTokens(); - // List leads: it's the at-a-glance "what's coming up" the user wants first, - // with Week/Month as the calendar grids behind it. + // List leads: it's the at-a-glance "what's coming up" the user wants first, with Week/Month as the calendar grids behind it. const [calendarView, setCalendarView] = useState<'Week' | 'Month' | 'List'>('List'); const [refDate, setRefDate] = useState(() => new Date()); const workflows = useAppSelector((s) => s.workflows.items); @@ -94,9 +93,7 @@ export default function SchedulePopover({ return m; }, [workflows]); - // Both Search and Schedule modes render at the same fixed dimensions so - // toggling chips doesn't resize the popover. Schedule sets the floor: - // its 7-day calendar needs ~620w x ~420h, search inherits the same. + // Both Search and Schedule modes render at the same fixed dimensions so toggling chips doesn't resize the popover. Schedule sets the floor: its 7-day calendar needs ~620w x ~420h, search inherits the same. const POPOVER_W = 620; const CONTENT_H = 420; @@ -244,8 +241,7 @@ export default function SchedulePopover({ ); } -// Floating chip rendered ABOVE the popover card (image #30). Active gets a -// subtle filled-elevated bg + 1px border; inactive is borderless ghost. +// Floating chip rendered ABOVE the popover card (image #30). Active gets a subtle filled-elevated bg + 1px border; inactive is borderless ghost. function ModeChip({ label, icon, active, onClick }: { label: string; icon: React.ReactNode; active: boolean; onClick: () => void }) { const c = useClaudeTokens(); return ( diff --git a/frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx b/frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx index e4e3d6c4..a53f1012 100644 --- a/frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleTestWarningDialog.tsx @@ -14,9 +14,7 @@ interface Props { onScheduleAnyway: () => void; } -// Shown before scheduling a workflow whose current steps haven't been validated -// by a test run. Scheduled fires can't pause to ask for tool permission, so an -// untested workflow that needs approval would silently fail on its first run. +// Shown before scheduling a workflow whose current steps haven't been validated by a test run. Scheduled fires can't pause to ask for tool permission, so an untested workflow that needs approval would silently fail on its first run. export default function ScheduleTestWarningDialog({ open, onClose, onTestFirst, onScheduleAnyway }: Props) { const c = useClaudeTokens(); return ( diff --git a/frontend/src/app/pages/Workflows/StepList.tsx b/frontend/src/app/pages/Workflows/StepList.tsx index 9e8caf24..64581389 100644 --- a/frontend/src/app/pages/Workflows/StepList.tsx +++ b/frontend/src/app/pages/Workflows/StepList.tsx @@ -1,13 +1,4 @@ -// Vertical step list, the one shared building block across every -// workflow card subview. Supports three orthogonal modes that compose: -// -// editable onChangeStep is set -> each row is a TextareaAutosize -// (PreviewView only). -// expandable expandable=true -> chevron next to each title; -// click reveals the raw prompt body. -// live stepStatuses is set -> per-step circle becomes done/active/ -// failed; Running view also surfaces -// activeStepSubtitle + duration. +// Vertical step list, the one shared building block across every workflow card subview. Supports three orthogonal modes that compose: editable onChangeStep is set -> each row is a TextareaAutosize (PreviewView only). expandable expandable=true -> chevron next to each title; click reveals the raw prompt body. live stepStatuses is set -> per-step circle becomes done/active/ failed; Running view also surfaces activeStepSubtitle + duration. import React from 'react'; import Box from '@mui/material/Box'; @@ -72,8 +63,7 @@ export default function StepList(props: Props) { left: CONNECTOR_X - 0.5, top: CIRCLE_SIZE * 0.5, bottom: CIRCLE_SIZE * 0.5, - // '1px' not 1: MUI sx treats width:1 as 100%, which rendered the - // connector as a full-width grey band behind the steps. + // '1px' not 1: MUI sx treats width:1 as 100%, which rendered the connector as a full-width grey band behind the steps. width: '1px', bgcolor: c.border.medium, opacity: 0.65, diff --git a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx index 9ae833f5..b1c6b465 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardLiveViews.tsx @@ -1,7 +1,4 @@ -// Run-state views for the workflow card. The card's `view` field flips to -// 'running' / 'completed' / 'failed' off of the workflow:run ws stream -// (see upsertRun reducer). Each view here renders the same step list -// with a different status overlay + a different footer. +// Run-state views for the workflow card. The card's `view` field flips to 'running' / 'completed' / 'failed' off of the workflow:run ws stream (see upsertRun reducer). Each view here renders the same step list with a different status overlay + a different footer. import React, { useCallback, useMemo } from 'react'; import Box from '@mui/material/Box'; @@ -30,9 +27,7 @@ import { fetchSession, closeSession, collapseSession } from '@/shared/state/agen import type { AppDispatch } from '@/shared/state/store'; import StepList, { type StepStatus } from './StepList'; -// Unlink the sidecar AND close the chat card it opened. closeSession is -// what makes removal stick: a bare removeCard gets re-added by -// reconcileSessions since the run session shares the dashboard. +// Unlink the sidecar AND close the chat card it opened. closeSession is what makes removal stick: a bare removeCard gets re-added by reconcileSessions since the run session shares the dashboard. function stopViewingSidecar(dispatch: AppDispatch, workflowId: string, sessionId: string | null | undefined) { dispatch(setCardSidecar({ workflowId, sessionId: null, kind: null })); if (!sessionId) return; @@ -41,9 +36,7 @@ function stopViewingSidecar(dispatch: AppDispatch, workflowId: string, sessionId void dispatch(closeSession({ sessionId })); } -// Helper: open a session next to the workflow card AND mark the card as -// sidecar-linked so the footer flips to Stop Watching/Viewing and the -// dashboard draws an arrow chip between the two cards. +// Helper: open a session next to the workflow card AND mark the card as sidecar-linked so the footer flips to Stop Watching/Viewing and the dashboard draws an arrow chip between the two cards. export function useOpenSidecar(workflowId: string) { const dispatch = useAppDispatch(); return React.useCallback(async (sessionId: string, kind: 'watching' | 'viewing-completed' | 'viewing-error' | 'testing') => { @@ -152,9 +145,7 @@ export function RunningView({ workflow, steps, runs, mode = 'card' }: { const runId = card?.runId || null; const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]); - // Prefer the backend's real active_step_idx (broadcast on each step - // bump in executor.execute). Fall back to elapsed/expected heuristic - // when the field is missing (older runs or first-frame race). + // Prefer the backend's real active_step_idx (broadcast on each step bump in executor.execute). Fall back to elapsed/expected heuristic when the field is missing (older runs or first-frame race). const heuristicIdx = useActiveStepIdx(steps.length, runs, runId); const activeIdx = typeof run?.active_step_idx === 'number' ? run.active_step_idx : heuristicIdx; const statuses: StepStatus[] = steps.map((_, i) => @@ -163,9 +154,7 @@ export function RunningView({ workflow, steps, runs, mode = 'card' }: { const completeCount = statuses.filter((s) => s === 'done').length; const total = steps.length; - // Tool-call subtitle for the active step. Backend polls the session's - // messages at 1.5s cadence and broadcasts on workflow:run as the agent - // makes new tool calls. See executor.py _watch_tool_calls. + // Tool-call subtitle for the active step. Backend polls the session's messages at 1.5s cadence and broadcasts on workflow:run as the agent makes new tool calls. See executor.py _watch_tool_calls. const activeSubtitle = run?.last_tool_label || null; const activeDuration = formatLiveDuration(run); @@ -265,10 +254,7 @@ export function CompletedView({ workflow, steps, runs, mode = 'card' }: { const runId = card?.runId || null; const run = useMemo(() => (runs || []).find((r) => r.id === runId) || null, [runs, runId]); const statuses: StepStatus[] = steps.map(() => 'done'); - // A run that was being watched live stays tethered to the same chat when it - // finishes, so the still-'watching' kind counts as linked too (the slice's - // watching->viewing-completed flip can miss on fast runs). Without this the - // card offers "View Agent", which spawns a duplicate chat. + // A run that was being watched live stays tethered to the same chat when it finishes, so the still-'watching' kind counts as linked too (the slice's watching->viewing-completed flip can miss on fast runs). Without this the card offers "View Agent", which spawns a duplicate chat. const isLinked = mode === 'sidecar-linked' && (card?.sidecarKind === 'viewing-completed' || card?.sidecarKind === 'watching'); const onDone = useCallback(() => { @@ -372,9 +358,7 @@ export function FailedView({ workflow, steps, runs, mode = 'card' }: { const statuses: StepStatus[] = steps.map((_, i) => i < failedIdx ? 'done' : i === failedIdx ? 'failed' : 'pending', ); - // Same as CompletedView: a watched run that fails stays tethered, so treat the - // still-'watching' kind as linked and show Stop Viewing instead of View Error - // (which would open a second chat). + // Same as CompletedView: a watched run that fails stays tethered, so treat the still-'watching' kind as linked and show Stop Viewing instead of View Error (which would open a second chat). const isLinked = mode === 'sidecar-linked' && (card?.sidecarKind === 'viewing-error' || card?.sidecarKind === 'watching'); const onIgnore = useCallback(() => { @@ -464,9 +448,7 @@ export function FailedView({ workflow, steps, runs, mode = 'card' }: { function guessFailedIdx(run: WorkflowRun | null, total: number): number { if (!run) return Math.max(0, total - 1); - // Backend pins active_step_idx at the failed step before flipping - // status to 'failure'. Prefer that; fall back to parsing "Step N" - // out of the error string for legacy runs. + // Backend pins active_step_idx at the failed step before flipping status to 'failure'. Prefer that; fall back to parsing "Step N" out of the error string for legacy runs. if (typeof run.active_step_idx === 'number') { return Math.max(0, Math.min(total - 1, run.active_step_idx)); } @@ -480,12 +462,7 @@ function guessFailedIdx(run: WorkflowRun | null, total: number): number { return Math.max(0, Math.min(total - 1, 1)); } -// ---------- Header overrides ---------- -// The card header normally renders {History | Run}. Running shows -// {Stop | Pause}, Completed/Failed keep {History | Run}, Edit/Fix shows -// {Discard | Save}, Scheduling shows {Cancel task scheduling}. The -// WorkflowCard hands off via this helper so each view can declare its -// own header without the parent fanning out a switch. +// ---------- Header overrides ---------- The card header normally renders {History | Run}. Running shows {Stop | Pause}, Completed/Failed keep {History | Run}, Edit/Fix shows {Discard | Save}, Scheduling shows {Cancel task scheduling}. The WorkflowCard hands off via this helper so each view can declare its own header without the parent fanning out a switch. export interface HeaderActions { left?: React.ReactNode; diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 8b71bbe2..e0296df5 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -66,10 +66,7 @@ type ActionBtnTone = 'muted' | 'success' | 'danger'; export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: string; tone: ActionBtnTone; disabled?: boolean; onClick: () => void; icon?: 'trash' | 'check' }) { const c = useClaudeTokens(); - // Tone -> color triple. Matches target #58/#63 styling: - // success = green pill (Save) - // danger = red/pink pill (Discard) - // muted = neutral pill (Undo) + // Tone -> color triple. Matches target #58/#63 styling: success = green pill (Save) danger = red/pink pill (Discard) muted = neutral pill (Undo) const palette = tone === 'success' ? { color: c.status.success, bg: c.status.successBg, border: c.status.success + '60', hover: c.status.success + '30' } : tone === 'danger' @@ -80,8 +77,7 @@ export function ActionBtn({ label, tone, disabled, onClick, icon }: { label: str onClick={disabled ? undefined : onClick} role="button" sx={{ - // Compact pill matching target #58/#63. Smaller padding + smaller - // glyphs so the buttons stop overshadowing the step body. + // Compact pill matching target #58/#63. Smaller padding + smaller glyphs so the buttons stop overshadowing the step body. display: 'inline-flex', alignItems: 'center', gap: 0.4, fontSize: '0.78rem', fontWeight: 600, px: 1, py: 0.35, @@ -117,25 +113,16 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, const dispatch = useAppDispatch(); const [busy, setBusy] = useState(false); const [savePromptOpen, setSavePromptOpen] = useState(false); - // Title + description live in the openCard draft so the parent header - // (which renders the inline-editable title) and PreviewView body (which - // renders the inline-editable description + steps) stay in sync. On - // Save we pull whatever's currently in the draft, falling back to the - // initialDraft passed at mount time. + // Title + description live in the openCard draft so the parent header (which renders the inline-editable title) and PreviewView body (which renders the inline-editable description + steps) stay in sync. On Save we pull whatever's currently in the draft, falling back to the initialDraft passed at mount time. const card = useAppSelector((s) => s.workflows.openCards[workflowId]); const liveDraft = (card?.draft ?? initialDraft ?? {}) as Partial; const title = (liveDraft.title as string) || 'New workflow'; const description = (liveDraft.description as string) || ''; const canSave = steps.some((s) => (s.text || '').trim().length > 0); - // The new workflow runs with the user's configured default model/mode (their - // subscription, etc.), falling back to whatever the source chat used. Without - // this the backend picks its own default, which surprised users who'd set a - // subscription default but saw the workflow created on an API-key model. + // The new workflow runs with the user's configured default model/mode (their subscription, etc.), falling back to whatever the source chat used. Without this the backend picks its own default, which surprised users who'd set a subscription default but saw the workflow created on an API-key model. const defaultModel = useAppSelector((s) => s.settings.data.default_model); const defaultMode = useAppSelector((s) => s.settings.data.default_mode); - // Steps render compact (label + chevron, capped + "... N more"), same as - // the saved card. The raw prompt drills down on click. Keeping them short - // is what leaves room for the schedule prompt + buttons to stay on-card. + // Steps render compact (label + chevron, capped + "... N more"), same as the saved card. The raw prompt drills down on click. Keeping them short is what leaves room for the schedule prompt + buttons to stay on-card. const expandedIds = card?.expandedStepIds || []; const onToggleStep = useCallback((stepId: string) => { dispatch(toggleExpandedStep({ workflowId, stepId })); @@ -173,12 +160,10 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, metadata_generated: card?.metaGenerated === true, source_session_id: sourceSessionId, use_synced_prompt: true, - // The user's configured default wins over whatever model the source chat - // happened to run on, so a converted workflow behaves like a fresh chat. + // The user's configured default wins over whatever model the source chat happened to run on, so a converted workflow behaves like a fresh chat. model: defaultModel || (liveDraft.model as string), mode: defaultMode || (liveDraft.mode as string), - // Converting a chat carries its prior approvals, so count it as already - // validated for these steps: scheduling won't nag to test first. + // Converting a chat carries its prior approvals, so count it as already validated for these steps: scheduling won't nag to test first. tested_signature: sourceSessionId ? stepsSignature(steps) : undefined, } as Partial)); if (!createWorkflow.fulfilled.match(result)) return null; @@ -310,8 +295,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, ); } -// Render the workflow's permission tiers as a flat prose line so the -// SavedView reads like a sentence, not a chip salad. Mirrors target #54. +// Render the workflow's permission tiers as a flat prose line so the SavedView reads like a sentence, not a chip salad. Mirrors target #54. function describePermissions(workflow: Workflow): string { const tiers = workflow.permissions || []; if (tiers.length === 0) return 'Notify me in Open Swarm'; @@ -340,8 +324,7 @@ function describeSchedule(workflow: Workflow): string { if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`; if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `Weekends at ${time}`; if (s.on_days.length === 1) { - // Image #50: "Mondays at 3pm" (plural day, no "Every" prefix). Reads - // more naturally than "Every Mon at 3pm". + // Image #50: "Mondays at 3pm" (plural day, no "Every" prefix). Reads more naturally than "Every Mon at 3pm". const plurals = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays']; return `${plurals[s.on_days[0]]} at ${time}`; } @@ -363,8 +346,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo const openScheduling = useCallback(() => { dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'scheduling', showScheduleNudge: false } })); }, [dispatch, workflow.id]); - // Gate the schedule action: warn first if the current steps haven't been - // validated by a test run (so an unattended fire won't silently deny a tool). + // Gate the schedule action: warn first if the current steps haven't been validated by a test run (so an unattended fire won't silently deny a tool). const requestSchedule = useCallback(() => { if (needsScheduleTestWarning(workflow)) { setWarnOpen(true); return; } openScheduling(); @@ -394,9 +376,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo } }, [deletingStepId, dispatch, workflow.id, workflow.steps, workflow.updated_at]); - // "Not now" on the post-convert nudge doesn't dump you on a near-identical - // saved card: the workflow is already saved (find it in the hub), so we drop - // its card and reopen the chat it came from, right in the same slot. + // "Not now" on the post-convert nudge doesn't dump you on a near-identical saved card: the workflow is already saved (find it in the hub), so we drop its card and reopen the chat it came from, right in the same slot. const wfCardPos = useAppSelector((s) => s.dashboardLayout.workflowCards[workflow.id]); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); const sourceId = workflow.source_session_id || null; @@ -417,8 +397,7 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo const scheduleConfigured = isScheduleConfigured(workflow.schedule); const scheduleLine = workflow.schedule.enabled && scheduleConfigured ? describeSchedule(workflow) : 'Schedule this workflow'; const scheduleClickable = !scheduleConfigured; - // One-shot prompt right after a convert; hub-opened cards never set the flag, - // so they fall straight to the quiet schedule line below. + // One-shot prompt right after a convert; hub-opened cards never set the flag, so they fall straight to the quiet schedule line below. const showNudge = !!card?.showScheduleNudge && !scheduleConfigured; return ( @@ -524,12 +503,10 @@ export function SavedView({ workflow, steps, runs, activeRunId }: { workflow: Wo ); } -// kept on file for legacy uses; once the audit popover migrates, this and -// the StreakBadge / habit-suggestion blocks above can be deleted entirely. +// kept on file for legacy uses; once the audit popover migrates, this and the StreakBadge / habit-suggestion blocks above can be deleted entirely. void StreakBadgeRow; -// Splits StreakBadge out so the SavedView body doesn't have to ferry -// the runs array through both the chip row (gone) and the step list. +// Splits StreakBadge out so the SavedView body doesn't have to ferry the runs array through both the chip row (gone) and the step list. function StreakBadgeRow({ runs }: { runs?: WorkflowRun[] }) { if (!runs || runs.length === 0) return null; return ( @@ -539,17 +516,13 @@ function StreakBadgeRow({ runs }: { runs?: WorkflowRun[] }) { ); } -// Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit -// on open, renders a compact list. The trigger sits inline with the chip -// row so power users can spot it without cluttering the title. +// Audit-trace popover. Lazy-fetches the last N edits from /workflows/{id}/audit on open, renders a compact list. The trigger sits inline with the chip row so power users can spot it without cluttering the title. function AuditTraceLink({ workflowId }: { workflowId: string }) { const c = useClaudeTokens(); const [anchor, setAnchor] = useState(null); const [entries, setEntries] = useState }> | null>(null); const [loading, setLoading] = useState(false); - // Probe the audit log once on mount so we can hide the trigger entirely - // when there are no edits (item #21 in target #54 diff). Fire-and-forget; - // a failure leaves entries=null which renders nothing. + // Probe the audit log once on mount so we can hide the trigger entirely when there are no edits (item #21 in target #54 diff). Fire-and-forget; a failure leaves entries=null which renders nothing. React.useEffect(() => { let alive = true; (async () => { @@ -567,11 +540,7 @@ function AuditTraceLink({ workflowId }: { workflowId: string }) { })(); return () => { alive = false; }; }, [workflowId]); - // The popover open handler must be declared BEFORE the conditional - // return below; otherwise React sees a different hook-count between - // the "loading" render (returns early) and the "loaded with entries" - // render (calls useCallback), which triggers the "Rendered more hooks - // than during the previous render" crash. + // The popover open handler must be declared BEFORE the conditional return below; otherwise React sees a different hook-count between the "loading" render (returns early) and the "loaded with entries" render (calls useCallback), which triggers the "Rendered more hooks than during the previous render" crash. const open = useCallback(async (e: React.MouseEvent) => { setAnchor(e.currentTarget); if (entries !== null) return; @@ -662,8 +631,7 @@ function runDuration(r: WorkflowRun): string | null { } catch { return null; } } -// Groups runs into "This week / Last week / Month YYYY" buckets so a -// long history list reads as eras rather than 50 same-looking dates. +// Groups runs into "This week / Last week / Month YYYY" buckets so a long history list reads as eras rather than 50 same-looking dates. function groupKey(iso: string): string { try { const d = new Date(iso); @@ -681,8 +649,7 @@ function groupKey(iso: string): string { export function HistoryList({ runs, onOpen, showWorkflow = false, workflowTitleFor }: { runs: WorkflowRun[]; onOpen: (r: WorkflowRun) => void; showWorkflow?: boolean; workflowTitleFor?: (workflowId: string) => string }) { const c = useClaudeTokens(); const [expandedId, setExpandedId] = useState(null); - // Filter chips: all / success / failures / skipped. Power-users debugging a - // flaky workflow shouldn't have to scroll past the runs they don't care about. + // Filter chips: all / success / failures / skipped. Power-users debugging a flaky workflow shouldn't have to scroll past the runs they don't care about. const [filter, setFilter] = useState<'all' | 'success' | 'failure' | 'skipped'>('all'); const filtered = useMemo(() => { if (filter === 'all') return runs; diff --git a/frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx b/frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx index 6a0ffdcc..5dd9c6e6 100644 --- a/frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowRunningToast.tsx @@ -1,7 +1,4 @@ -// Clickable "your {workflow} is running now" nudge for scheduled runs that -// fire while the user isn't looking. Detection lives in the upsertRun reducer -// (it owns the into-running edge); this just renders the redux toast state and, -// on View, opens the Workflows app to that workflow's live detail. +// Clickable "your {workflow} is running now" nudge for scheduled runs that fire while the user isn't looking. Detection lives in the upsertRun reducer (it owns the into-running edge); this just renders the redux toast state and, on View, opens the Workflows app to that workflow's live detail. import React from 'react'; import Snackbar from '@mui/material/Snackbar'; diff --git a/frontend/src/app/pages/Workflows/app/CalendarView.tsx b/frontend/src/app/pages/Workflows/app/CalendarView.tsx index c54b0585..3a7a4282 100644 --- a/frontend/src/app/pages/Workflows/app/CalendarView.tsx +++ b/frontend/src/app/pages/Workflows/app/CalendarView.tsx @@ -37,8 +37,7 @@ const tabBtn = (active: boolean, WC: WCPalette): CSSProperties => ({ const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => { const WC = useWC(); const items = useAppSelector((s) => s.workflows.items); - // Tick the clock so the now-line and "today" highlight stay live instead of - // freezing at first render. + // Tick the clock so the now-line and "today" highlight stay live instead of freezing at first render. const [now, setNow] = useState(() => new Date()); useEffect(() => { const id = setInterval(() => setNow(new Date()), 60000); @@ -47,9 +46,7 @@ const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => { const ref = nav.refDate; const refKey = `${ref.getFullYear()}-${ref.getMonth()}-${ref.getDate()}`; - // Window of occurrences spanning the visible month grid (covers week too). - // Fired times come from the backend's recurrence engine, not a JS reimpl, so - // the grid matches what actually runs (timezone + last-day-of-month aware). + // Window of occurrences spanning the visible month grid (covers week too). Fired times come from the backend's recurrence engine, not a JS reimpl, so the grid matches what actually runs (timezone + last-day-of-month aware). const { fromIso, toIso } = useMemo(() => { const from = startOfMonthGrid(ref); return { fromIso: from.toISOString(), toIso: addDays(from, 42).toISOString() }; @@ -84,8 +81,7 @@ const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => { else nav.setRefDate(new Date(ref.getFullYear(), ref.getMonth() + dir, 1)); }; - // Click "+N more" to peek a day's/hour's full run list. position:fixed via a - // body portal so it isn't reparented by the zoomed/panned canvas transform. + // Click "+N more" to peek a day's/hour's full run list. position:fixed via a body portal so it isn't reparented by the zoomed/panned canvas transform. const [dayPop, setDayPop] = useState(null); const openDayPop: OpenDayPop = (popTitle, runs, e) => { e.stopPropagation(); @@ -155,16 +151,13 @@ const moreStyle = (WC: WCPalette): CSSProperties => ({ const MonthGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect, openDayPop }) => { const WC = useWC(); const start = startOfMonthGrid(ref0); - // Only as many weeks as the month actually spans (5 or 6), like the design, - // so rows aren't squashed by a dangling extra week of next-month days. + // Only as many weeks as the month actually spans (5 or 6), like the design, so rows aren't squashed by a dangling extra week of next-month days. const monthEnd = new Date(ref0.getFullYear(), ref0.getMonth() + 1, 0); const weeks = Math.ceil((Math.round((monthEnd.getTime() - start.getTime()) / 86400000) + 1) / 7); const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(start, i)); const month = ref0.getMonth(); - // Cells shrink with the window, so a fixed cap clips. Measure the real row and - // "+more" heights off hidden probes (font metrics vary), then fit only events - // that fully fit, the rest roll into "+N more". No guessed pixel constants. + // Cells shrink with the window, so a fixed cap clips. Measure the real row and "+more" heights off hidden probes (font metrics vary), then fit only events that fully fit, the rest roll into "+N more". No guessed pixel constants. const gridRef = useRef(null); const probeEventRef = useRef(null); const probeMoreRef = useRef(null); @@ -249,10 +242,7 @@ const WeekGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect, const ROW_H = 44; useEffect(() => { - // Scroll to ~2h before now once per mount / week change. Keying this on the - // fresh `now`/`start` objects re-ran it on every render, so any background - // re-render (a live run streaming, ongoing-runs updating) yanked the scroll - // back up, you could never sit at the bottom. Key off the stable week ms. + // Scroll to ~2h before now once per mount / week change. Keying this on the fresh `now`/`start` objects re-ran it on every render, so any background re-render (a live run streaming, ongoing-runs updating) yanked the scroll back up, you could never sit at the bottom. Key off the stable week ms. const el = scrollRef.current; if (el) el.scrollTop = Math.max(0, (new Date().getHours() - 2) * ROW_H); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/app/pages/Workflows/app/ColorSwatch.tsx b/frontend/src/app/pages/Workflows/app/ColorSwatch.tsx index 2eb9d3c2..6e32686d 100644 --- a/frontend/src/app/pages/Workflows/app/ColorSwatch.tsx +++ b/frontend/src/app/pages/Workflows/app/ColorSwatch.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useWC, WORKFLOW_PALETTE } from './uiKit'; -// Small swatch button that opens a palette popover. Self-rendered (no portal) -// so it survives the canvas compositor, same reasoning as RepeatField. +// Small swatch button that opens a palette popover. Self-rendered (no portal) so it survives the canvas compositor, same reasoning as RepeatField. const ColorSwatch: React.FC<{ value: string; onChange: (hex: string) => void; size?: number }> = ({ value, onChange, size = 14 }) => { const WC = useWC(); const [open, setOpen] = useState(false); diff --git a/frontend/src/app/pages/Workflows/app/ComposeView.tsx b/frontend/src/app/pages/Workflows/app/ComposeView.tsx index 4c4373d7..26ddab7c 100644 --- a/frontend/src/app/pages/Workflows/app/ComposeView.tsx +++ b/frontend/src/app/pages/Workflows/app/ComposeView.tsx @@ -16,10 +16,7 @@ import StepsCard from './StepsCard'; import SaveGuard from './SaveGuard'; import type { AppNav } from './types'; -// Short pill label for the clean cluster, plus the richer prompt actually sent -// so the agent gets real detail. Spread across personas (work, money, research, -// lifestyle, monitoring) so most people see one that fits. Keep labels similar -// length so they cluster two-per-row. +// Short pill label for the clean cluster, plus the richer prompt actually sent so the agent gets real detail. Spread across personas (work, money, research, lifestyle, monitoring) so most people see one that fits. Keep labels similar length so they cluster two-per-row. const NEW_CHIPS: Array<{ label: string; prompt: string }> = [ { label: 'Summarize my inbox daily', prompt: 'Each morning, summarize my inbox and draft replies to the important emails.' }, { label: 'Recap my weekly spending', prompt: 'Every Sunday, recap my spending, subscriptions, upcoming bills, and any weird charges.' }, @@ -42,8 +39,7 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { const workflow = useAppSelector((s) => (draftId ? s.workflows.items[draftId] : undefined)); - // One unsaved draft per visit to "New". The backend hides unsaved drafts from - // lists, so an abandoned one stays out of the way until GC. + // One unsaved draft per visit to "New". The backend hides unsaved drafts from lists, so an abandoned one stays out of the way until GC. useEffect(() => { if (created.current) return; created.current = true; @@ -59,23 +55,15 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { const session = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId] : undefined)); const agentBusy = session?.status === 'running' || session?.status === 'waiting_approval'; const visibleMsgs = (session?.messages || []).filter((m) => !m.hidden).length; - // The agent has actually said something back, not just the user's own message - // sitting alone in the gap before the turn even starts. Gate the pane + handoff - // on THIS: "any message exists" is true the instant you send, so it used to - // fling you to the detail page before the agent ever replied. + // The agent has actually said something back, not just the user's own message sitting alone in the gap before the turn even starts. Gate the pane + handoff on THIS: "any message exists" is true the instant you send, so it used to fling you to the detail page before the agent ever replied. const agentReplied = (session?.messages || []).some((m) => m.role === 'assistant' && !m.hidden); - // Landing state: the blank page (incl. before the session has loaded, so the - // right pane starts closed rather than open-then-snap-shut). Gone the moment a - // message lands or the agent starts working, so its "thinking" never shows here. + // Landing state: the blank page (incl. before the session has loaded, so the right pane starts closed rather than open-then-snap-shut). Gone the moment a message lands or the agent starts working, so its "thinking" never shows here. const composeEmpty = !agentBusy && visibleMsgs === 0; - // Open the pane only once the agent has fully answered (not mid-response, where - // the chat is reflowing and the slide looks janky). Header toggle overrides it. + // Open the pane only once the agent has fully answered (not mid-response, where the chat is reflowing and the slide looks janky). Header toggle overrides it. const autoOpen = !agentBusy && agentReplied; const paneOpen = paneManual ?? autoOpen; - // Once it's revealed (in the sidebar) AND the agent has finished its first - // answer, hand off to the detail page, after a beat so the right-pane open - // animation plays here first. Same sticky edit session, so the chat carries over. + // Once it's revealed (in the sidebar) AND the agent has finished its first answer, hand off to the detail page, after a beat so the right-pane open animation plays here first. Same sticky edit session, so the chat carries over. useEffect(() => { if (handedOff.current || !draftId || !workflow) return; if (workflow.unsaved === false && autoOpen) { @@ -90,17 +78,13 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { dispatch(sendMessage({ sessionId, prompt: text, mode: session.mode, model: session.model })); }; - // The conversation started, so the workflow is real: reveal it under Workflows - // (and hand off to its detail) right away. The title stays "Untitled workflow" - // until the first step lands and the backend auto-names it (auto_named stays - // true), so the name types in from the steps, not the raw prompt. + // The conversation started, so the workflow is real: reveal it under Workflows (and hand off to its detail) right away. The title stays "Untitled workflow" until the first step lands and the backend auto-names it (auto_named stays true), so the name types in from the steps, not the raw prompt. const revealed = useRef(false); const firstUserMsg = (session?.messages || []).find((m) => m.role === 'user' && !m.hidden); useEffect(() => { if (revealed.current || !workflow || !firstUserMsg || workflow.unsaved === false) return; revealed.current = true; - // Reveal immediately so it lands in the sidebar the moment you send; the - // handoff to detail waits for the agent to finish (see effect above). + // Reveal immediately so it lands in the sidebar the moment you send; the handoff to detail waits for the agent to finish (see effect above). dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false } })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [firstUserMsg, workflow?.unsaved, dispatch]); @@ -125,9 +109,7 @@ const ComposeView: React.FC<{ nav: AppNav }> = ({ nav }) => { finally { setTesting(false); } }; - // No steps / no title is fine, you can save a bare workflow and fill it in - // later. No If-Match: this is the user's own brand-new draft, so there's no - // concurrent edit to guard against and a stale stamp shouldn't block the save. + // No steps / no title is fine, you can save a bare workflow and fill it in later. No If-Match: this is the user's own brand-new draft, so there's no concurrent edit to guard against and a stale stamp shouldn't block the save. const finalizeSave = () => { dispatch(updateWorkflow({ id: workflow.id, patch: { unsaved: false } })); nav.selectWorkflow(workflow.id); diff --git a/frontend/src/app/pages/Workflows/app/DetailView.tsx b/frontend/src/app/pages/Workflows/app/DetailView.tsx index 792660fe..2000f7d4 100644 --- a/frontend/src/app/pages/Workflows/app/DetailView.tsx +++ b/frontend/src/app/pages/Workflows/app/DetailView.tsx @@ -26,8 +26,7 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId const sessionId = useEditAgentSession(workflowId); const detailRuns = useAppSelector((s) => s.workflows.runs[workflowId]); const runContext = useAppSelector((s) => s.dashboardLayout.workflowsRunContext); - // When you Run now from this chat, attach that run as a context chip once it - // finishes, so the next question rides on its transcript (removable, no popup). + // When you Run now from this chat, attach that run as a context chip once it finishes, so the next question rides on its transcript (removable, no popup). const autoCtxRunId = useRef(null); useEffect(() => { diff --git a/frontend/src/app/pages/Workflows/app/HomeView.tsx b/frontend/src/app/pages/Workflows/app/HomeView.tsx index 98bf02b2..0dad456b 100644 --- a/frontend/src/app/pages/Workflows/app/HomeView.tsx +++ b/frontend/src/app/pages/Workflows/app/HomeView.tsx @@ -44,8 +44,7 @@ const HomeView: React.FC<{ nav: AppNav }> = ({ nav }) => { }; }), [active, items, allRuns]); - // Fetch the 7-day window from the backend's recurrence engine (single source - // of truth) instead of recomputing fire times in JS. + // Fetch the 7-day window from the backend's recurrence engine (single source of truth) instead of recomputing fire times in JS. const dayKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`; const { fromIso, toIso } = useMemo(() => { const from = new Date(now); from.setHours(0, 0, 0, 0); diff --git a/frontend/src/app/pages/Workflows/app/RepeatField.tsx b/frontend/src/app/pages/Workflows/app/RepeatField.tsx index 66796a82..0ee22aa3 100644 --- a/frontend/src/app/pages/Workflows/app/RepeatField.tsx +++ b/frontend/src/app/pages/Workflows/app/RepeatField.tsx @@ -1,9 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useWC } from './uiKit'; -// Combobox for the run limit: pick a preset OR type any count. Self-rendered -// (no native , no portal) because a native popup over the canvas card is a separate compositor layer and gets dismissed before you can click it. const OPTIONS: Array<{ label: string; val: number | null }> = [ { label: 'Forever', val: null }, { label: 'Once', val: 1 }, diff --git a/frontend/src/app/pages/Workflows/app/RunMonitor.tsx b/frontend/src/app/pages/Workflows/app/RunMonitor.tsx index 5ced118c..b79ef08d 100644 --- a/frontend/src/app/pages/Workflows/app/RunMonitor.tsx +++ b/frontend/src/app/pages/Workflows/app/RunMonitor.tsx @@ -43,9 +43,7 @@ interface Props { onDragEnd: (dx: number, dy: number, didDrag: boolean) => void; } -// The live run view, a real canvas card (standard claudeTokens chrome) spawned -// beside the Workflows window. The orange connector back to the window is drawn -// by the shared TetherLayer, same mechanism as an agent spinning up a browser. +// The live run view, a real canvas card (standard claudeTokens chrome) spawned beside the Workflows window. The orange connector back to the window is drawn by the shared TetherLayer, same mechanism as an agent spinning up a browser. const RunMonitor: React.FC = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, zoom, panX, panY, onDragStart, onDragMove, onDragEnd }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); diff --git a/frontend/src/app/pages/Workflows/app/SaveGuard.tsx b/frontend/src/app/pages/Workflows/app/SaveGuard.tsx index f89cc2b8..6b4734e1 100644 --- a/frontend/src/app/pages/Workflows/app/SaveGuard.tsx +++ b/frontend/src/app/pages/Workflows/app/SaveGuard.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { useWC } from './uiKit'; -// Test-first nudge before scheduling: a test run grants the tool access the -// workflow needs, so unattended runs don't stall reaching for them. +// Test-first nudge before scheduling: a test run grants the tool access the workflow needs, so unattended runs don't stall reaching for them. const SaveGuard: React.FC<{ title: string; onClose: () => void; diff --git a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx index c26748a1..a9ab59a5 100644 --- a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx +++ b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx @@ -21,11 +21,7 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const patchSched = (p: Partial) => patch(workflow, { schedule: { ...sched, ...p } }); - // The "Run at" field is an uncontrolled native time input so React doesn't - // reset the segment's pending-digit state between keystrokes (a controlled - // value made typing 4 then 5 land 05 instead of 45). To still reflect edits - // from elsewhere (e.g. the agent reschedules), push the store time in - // imperatively, and only when it actually differs from what's shown. + // The "Run at" field is an uncontrolled native time input so React doesn't reset the segment's pending-digit state between keystrokes (a controlled value made typing 4 then 5 land 05 instead of 45). To still reflect edits from elsewhere (e.g. the agent reschedules), push the store time in imperatively, and only when it actually differs from what's shown. const timeRef = useRef(null); useEffect(() => { const el = timeRef.current; @@ -34,9 +30,7 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { if (el.value !== want) el.value = want; }, [sched.hour, sched.minute]); - // Turning a weekly schedule on with no days picked is "unconfigured", so the - // backend silently forces it back off and the switch looks dead. Seed today's - // weekday so the default Weekly 9am toggles on (and stays on) in one click. + // Turning a weekly schedule on with no days picked is "unconfigured", so the backend silently forces it back off and the switch looks dead. Seed today's weekday so the default Weekly 9am toggles on (and stays on) in one click. const toggleEnabled = () => { if (!enabled && sched.repeat_unit === 'week' && sched.on_days.length === 0) { patchSched({ enabled: true, on_days: [new Date().getDay()] }); @@ -45,8 +39,7 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { } }; - // Local draft so the interval field can be cleared / mid-typed below the - // floor without snapping; we warn instead and commit a valid value. + // Local draft so the interval field can be cleared / mid-typed below the floor without snapping; we warn instead and commit a valid value. const [intervalDraft, setIntervalDraft] = useState(null); const freqBtn = (active: boolean): CSSProperties => ({ @@ -79,8 +72,7 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { // Picking a finite limit resets the lifetime counter so "run 3 times" always means 3 from now. const setMaxRuns = (n: number | null) => patchSched(n == null ? { max_runs: null } : { max_runs: n, runs_count: 0 }); - // A scheduled workflow with no steps fires but does nothing, so flag it. - // Mirror the test-warning's draft-or-live read so the banner tracks edits. + // A scheduled workflow with no steps fires but does nothing, so flag it. Mirror the test-warning's draft-or-live read so the banner tracks edits. const hasNoSteps = !(workflow.draft_steps ?? workflow.steps ?? []).some((s) => s.text && s.text.trim()); return ( diff --git a/frontend/src/app/pages/Workflows/app/StepsCard.tsx b/frontend/src/app/pages/Workflows/app/StepsCard.tsx index 662ac9c4..e4e7c686 100644 --- a/frontend/src/app/pages/Workflows/app/StepsCard.tsx +++ b/frontend/src/app/pages/Workflows/app/StepsCard.tsx @@ -22,10 +22,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const [local, setLocal] = useState(() => toLocal(workflow.steps)); const [draft, setDraft] = useState(''); - // Agent-proposed step changes apply silently (no Apply/Discard popup): commit - // any staged draft as soon as it lands so the steps just update live. Guarded - // on real content, the edit session snapshots an empty draft on open and - // committing that 400s. + // Agent-proposed step changes apply silently (no Apply/Discard popup): commit any staged draft as soon as it lands so the steps just update live. Guarded on real content, the edit session snapshots an empty draft on open and committing that 400s. useEffect(() => { if (workflow.has_draft && (workflow.draft_steps || []).some((s) => s.text && s.text.trim())) { dispatch(commitDraft({ id: workflow.id, keep_session: true })); @@ -33,8 +30,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { }, [workflow.has_draft, workflow.draft_steps, workflow.id, dispatch]); const sig = stepsSignature(workflow.steps); - // Reseed when the server steps change underneath us (commit, agent edit, - // another surface) but not on our own in-progress keystrokes. + // Reseed when the server steps change underneath us (commit, agent edit, another surface) but not on our own in-progress keystrokes. useEffect(() => { setLocal((prev) => { const openIds = new Set(prev.filter((s) => s.open).map((s) => s.id)); @@ -43,10 +39,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sig]); - // A manual add lands with an empty label that the backend names from the step - // text. The label arrives without changing the steps signature (same text), so - // fill it in here without a full reseed and without touching a label you're - // mid-typing. + // A manual add lands with an empty label that the backend names from the step text. The label arrives without changing the steps signature (same text), so fill it in here without a full reseed and without touching a label you're mid-typing. useEffect(() => { setLocal((prev) => { const byId = new Map(workflow.steps.map((s) => [s.id, s])); @@ -76,8 +69,7 @@ const StepsCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const onAdd = () => { const t = draft.trim(); if (!t) return; - // What you type is the step's prompt; the short label is generated from it - // server-side (empty label tells the backend to name this step). + // What you type is the step's prompt; the short label is generated from it server-side (empty label tells the backend to name this step). const next = [...local, { id: newStepId(), label: '', text: t, open: false, enabled: true }]; setLocal(next); setDraft(''); diff --git a/frontend/src/app/pages/Workflows/app/TrashView.tsx b/frontend/src/app/pages/Workflows/app/TrashView.tsx index a0878b74..cb0e3d9c 100644 --- a/frontend/src/app/pages/Workflows/app/TrashView.tsx +++ b/frontend/src/app/pages/Workflows/app/TrashView.tsx @@ -18,7 +18,7 @@ const TrashView: React.FC = () => { if (!window.confirm(`Permanently delete "${title}"? This can't be undone.`)) return; dispatch(purgeWorkflow(id)); }; -// when clicking a run and the run card pops up, make the card pop up slightly more to the right. +// when clicking a run and the run card pops up, make the card pop up slightly more to the right. return (
diff --git a/frontend/src/app/pages/Workflows/app/WorkflowTitle.tsx b/frontend/src/app/pages/Workflows/app/WorkflowTitle.tsx index 18a9dbe9..c340121d 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowTitle.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowTitle.tsx @@ -2,18 +2,14 @@ import React from 'react'; import { Typewriter } from '@/app/components/feedback/Animated'; interface WorkflowTitleProps { - // Raw title; the 'Untitled workflow' fallback is applied here so every surface - // agrees on the empty-name text. + // Raw title; the 'Untitled workflow' fallback is applied here so every surface agrees on the empty-name text. value: string | null | undefined; - // Animate AI-driven renames only. Pass `workflow.auto_named !== false`: a user - // rename flips auto_named false and the new title should just snap (they typed - // it), while the build agent's first-step rename types in like the chat card. + // Animate AI-driven renames only. Pass `workflow.auto_named !== false`: a user rename flips auto_named false and the new title should just snap (they typed it), while the build agent's first-step rename types in like the chat card. animate: boolean; children: (shown: string) => React.ReactNode; } -// One home for the workflow-title typewriter so every place a workflow name -// renders animates the same way AgentCard does when its name regenerates. +// One home for the workflow-title typewriter so every place a workflow name renders animates the same way AgentCard does when its name regenerates. export const WorkflowTitle: React.FC = ({ value, animate, children }) => ( {children} diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index 466fa786..c3d05311 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -14,8 +14,7 @@ import DetailView from './DetailView'; import ComposeView from './ComposeView'; import TrashView from './TrashView'; -// The three-pane Workflows body, independent of how it's framed (canvas card). -// Holds nav + data; the card chrome (title bar drag handle, resize) wraps it. +// The three-pane Workflows body, independent of how it's framed (canvas card). Holds nav + data; the card chrome (title bar drag handle, resize) wraps it. const WorkflowsAppContent: React.FC = () => { const WC = useWC(); const dispatch = useAppDispatch(); @@ -36,8 +35,7 @@ const WorkflowsAppContent: React.FC = () => { dispatch(fetchDeletedWorkflows(dashboardId)); }, [dashboardId, dispatch]); - // A deep-link target (history/notifications/toasts) jumps to that workflow's - // detail, then clears so a later manual Home nav isn't overridden. + // A deep-link target (history/notifications/toasts) jumps to that workflow's detail, then clears so a later manual Home nav isn't overridden. useEffect(() => { if (target) { setSelectedId(target); diff --git a/frontend/src/app/pages/Workflows/app/api.ts b/frontend/src/app/pages/Workflows/app/api.ts index 579be235..9009eb1f 100644 --- a/frontend/src/app/pages/Workflows/app/api.ts +++ b/frontend/src/app/pages/Workflows/app/api.ts @@ -8,8 +8,7 @@ function authHeaders(): Record { const base = `${API_BASE}/workflows`; -// Sticky single edit-agent session for a workflow. The backend snapshots steps -// into draft_steps when it first hands one out; reattaches on later calls. +// Sticky single edit-agent session for a workflow. The backend snapshots steps into draft_steps when it first hands one out; reattaches on later calls. export async function ensureEditAgentSession(workflowId: string): Promise { try { const res = await fetch(`${base}/${encodeURIComponent(workflowId)}/edit-agent-session`, { @@ -24,9 +23,7 @@ export async function ensureEditAgentSession(workflowId: string): Promise