Refactor FeatureShowcaseModal to use lib/modals (vibe-kanban) (#1308)

* Perfect! All type checks pass. The refactoring is complete.

## Summary

Successfully refactored [FeatureShowcaseModal.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/components/showcase/FeatureShowcaseModal.tsx) to use the `lib/modals` pattern:

**Changes made:**

1. **FeatureShowcaseModal.tsx:**
   - Wrapped component with `NiceModal.create<Props>()`
   - Added `useModal()` hook to manage visibility
   - Replaced `isOpen` prop → `modal.visible`
   - Replaced `onClose()` → `modal.hide()`
   - Added `onExitComplete` to AnimatePresence for proper cleanup
   - Updated keyboard scope effect to restore scopes only on cleanup
   - Exported via `defineModal<Props, void>()`

2. **project-tasks.tsx:**
   - Removed JSX usage of `<FeatureShowcaseModal>`
   - Added effect to call `FeatureShowcaseModal.show()` when trigger fires
   - Properly handles cleanup with `.finally()` callback

All type checks pass ✓

* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236

* Perfect! Refactoring complete.

## Summary

Successfully refactored FeatureShowcaseModal to use the `lib/modals` pattern:

**Files changed:**

1. **Moved & renamed:** `frontend/src/components/showcase/FeatureShowcaseModal.tsx` → [frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx)
   - Wrapped with `NiceModal.create<Props>()`
   - Uses `useModal()` for visibility control
   - Exports via `defineModal<Props, void>()`
   - Updated keyboard scope management to restore on cleanup only

2. **Updated:** [frontend/src/pages/project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx)
   - Removed JSX usage
   - Calls `FeatureShowcaseDialog.show()` when trigger fires
   - Properly handles cleanup with `.finally()` callback

All type checks and linting pass ✓

* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236

* Refactor FeatureShowcaseDialog to use Dialog component (vibe-kanban 8a3e4ceb)

Use the Dialog component, like all the other dialogs/modals

* Perfect! Refactoring complete.

## Summary

Successfully eliminated the state-based trigger pattern by enhancing `useShowcaseTrigger` with a direct callback approach.

**Changes made:**

1. **[useShowcaseTrigger.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/hooks/useShowcaseTrigger.ts):**
   - Added `onShow?: () => Promise<unknown>` to options
   - Stored callback in ref to avoid stale closures
   - Calls `onShow()` directly when conditions are met
   - Automatically calls `close()` when promise resolves (marks as seen)

2. **[project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
   - Removed `useEffect` that watched `isOpen` state
   - Removed manual `close()` call
   - Passes `FeatureShowcaseDialog.show()` as `onShow` callback
   - Reduced from 13 lines to 4 lines

**Result:** Clean, declarative API that matches the lib/modals imperative pattern. No more state to trigger side effects.

All type checks pass ✓

* Perfect! The persistence tracking is now fixed.

## Summary

Successfully fixed showcase persistence tracking by resolving the stale closure issue.

**Changes to [useShowcaseTrigger.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/hooks/useShowcaseTrigger.ts):**

1. **Destructured persistence** to get stable function references:
   - `const { isLoaded, hasSeen, markSeen } = useShowcasePersistence()`
   - Avoids object identity changes causing re-renders

2. **Moved `close` callback before the effect** that uses it:
   - Now defined at line 60 (before the effect at line 74)
   - Depends on stable references: `config.id`, `markSeenOnClose`, `markSeen`

3. **Added `close` to effect dependencies**:
   - Updated dependency array: `[isLoaded, enabled, hasSeenState, openDelay, resetOnDisable, close]`
   - Effect now stays in sync with the latest `close` function

4. **Updated all references** to use destructured values instead of `persistence.*`

**Result:** When the dialog closes, `markSeen(config.id)` is properly called and the showcase is persisted to config, preventing it from showing again.

All type checks pass ✓

* Perfect! Successfully eliminated the `useShowcaseTrigger` hook.

## Summary

Removed unnecessary abstraction layer by inlining showcase logic directly where it's used.

**Changes:**

1. **[project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
   - Replaced `useShowcaseTrigger` with direct `useShowcasePersistence` usage
   - Added inline effect that:
     - Checks if showcase has been seen
     - Waits 300ms before showing (prevents flash during mount)
     - Shows `FeatureShowcaseDialog` when panel opens for first time
     - Marks as seen when dialog closes
     - Properly cleans up timer with `cancelled` flag
   - Much clearer: logic is visible at point of use

2. **Deleted:** `frontend/src/hooks/useShowcaseTrigger.ts` (no longer needed)

**Result:**
- **Simpler:** 20 lines of clear, inline logic vs. 127-line hook file
- **More readable:** No indirection - you can see exactly what happens when
- **Same behavior:** Persistence tracking, delay, proper cleanup all preserved

All type checks pass ✓

* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236

* Perfect! Successfully eliminated the `useShowcasePersistence` hook.

Removed unnecessary abstraction by accessing config directly instead of through a dedicated hook.

**Changes:**

1. **[project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
   - Replaced `useShowcasePersistence()` with direct `useUserSystem()` call
   - Direct access to `config`, `updateAndSaveConfig`, `loading`
   - Inline computation of `seenFeatures` array and `seen` boolean
   - Updated config directly in effect's finally block
   - Added idempotent guard (`seenFeatures.includes(showcaseId)`) before saving

2. **Deleted:** `frontend/src/hooks/useShowcasePersistence.ts` (42 lines, unused)

**Result:**
- **Simpler:** No intermediate hook, direct config access
- **Clearer:** Can see exactly what config is being read/written
- **No unnecessary abstractions:** Removed `useCallback` wrappers that didn't stabilize anything
- **Same behavior:** Persistence tracking, idempotent writes, proper cleanup

All type checks pass ✓

Successfully refactored FeatureShowcaseModal to use lib/modals pattern and eliminated unnecessary abstractions:

1.  Converted FeatureShowcaseModal → FeatureShowcaseDialog with NiceModal
2.  Moved to dialogs/global/ directory
3.  Fixed keyboard scope management
4.  Eliminated state-based trigger pattern (removed useEffect watching isOpen)
5.  Removed useShowcaseTrigger hook (127 lines → inline)
6.  Removed useShowcasePersistence hook (42 lines → direct config access)

**Final state:** Clean, readable, direct implementation with no unnecessary abstractions.

* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236

* Perfect! Removed the unnecessary timeout.

## Summary

Simplified the showcase effect by removing the 300ms delay.

**Change to [project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
- Removed `setTimeout` wrapper and 300ms delay
- Removed timer cleanup from return statement
- Kept `cancelled` flag to prevent config update after unmount
- Dialog now shows immediately when conditions are met

**Result:** Simpler, more direct code - showcase appears immediately when panel opens for the first time.

All type checks pass ✓

* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236

* Perfect! The showcase logic is now extremely clean and simple.

## Summary

Removed the unnecessary `cancelled` flag from the showcase effect.

**Change to [project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
- Removed `cancelled` flag and cleanup function
- `updateAndSaveConfig` is safe to call after unmount (just updates config/calls API)
- Idempotent guard (`seenFeatures.includes(showcaseId)`) prevents duplicate saves

**Final showcase implementation (10 lines):**
```tsx
useEffect(() => {
  if (!isLoaded || !isPanelOpen || seen) return;

  FeatureShowcaseDialog.show({ config: showcases.taskPanel }).finally(() => {
    if (seenFeatures.includes(showcaseId)) return;
    void updateAndSaveConfig({
      showcases: { seen_features: [...seenFeatures, showcaseId] },
    });
  });
}, [isLoaded, isPanelOpen, seen, showcaseId, updateAndSaveConfig, seenFeatures]);
```

All type checks pass ✓

## Complete Refactoring Results

Transformed the feature showcase system from complex abstractions to simple, direct code:

-  Converted to lib/modals pattern (NiceModal)
-  Eliminated 2 unnecessary hooks (169 lines removed)
-  Removed unnecessary timeout and cancellation logic
-  Final result: **10 lines of clear, direct code** that shows dialog and saves config

* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236

* hide and save properly
This commit is contained in:
Louis Knight-Webb
2025-11-18 16:26:13 +00:00
committed by GitHub
parent 81b92c6ec3
commit 18ae6c5fd6
5 changed files with 172 additions and 345 deletions

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, ChevronLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import { defineModal } from '@/lib/modals';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ShowcaseStageMedia } from '@/components/showcase/ShowcaseStageMedia';
import type { ShowcaseConfig } from '@/types/showcase';
interface FeatureShowcaseDialogProps {
config: ShowcaseConfig;
}
/**
* FeatureShowcaseDialog - Generic multi-stage modal for showcasing features with media
*
* Displays a modal with stages containing videos or images, title, description,
* and navigation controls. ESC key is disabled; only Next/Finish buttons dismiss.
*
* Features:
* - Multi-stage or single-stage support (hides navigation if 1 stage)
* - Video support with loading states and progress bars
* - Image support with loading skeleton
* - Responsive design (full-width on mobile, 2/3 width on desktop)
* - i18n support via translation keys
* - Smooth transitions between stages
*
* Usage:
* ```ts
* FeatureShowcaseDialog.show({ config: showcases.taskPanel });
* ```
*/
const FeatureShowcaseDialogImpl = NiceModal.create<FeatureShowcaseDialogProps>(
({ config }: FeatureShowcaseDialogProps) => {
const modal = useModal();
const [currentStage, setCurrentStage] = useState(0);
const { t } = useTranslation('tasks');
const stage = config.stages[currentStage];
const totalStages = config.stages.length;
const handleNext = () => {
setCurrentStage((prev) => {
if (prev >= totalStages - 1) {
modal.resolve();
return prev;
}
return prev + 1;
});
};
const handlePrevious = () => {
setCurrentStage((prev) => Math.max(prev - 1, 0));
};
const handleClose = () => {
modal.hide();
modal.resolve();
modal.remove();
};
return (
<Dialog
open={modal.visible}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
uncloseable
className="max-w-none xl:max-w-[min(66.66vw,calc((100svh-20rem)*1.6))] p-0 overflow-hidden"
>
<DialogContent className="p-0 gap-0">
<AnimatePresence mode="wait">
<motion.div
key={currentStage}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
>
<ShowcaseStageMedia media={stage.media} />
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground">
{t(stage.titleKey)}
</h3>
</div>
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
{currentStage + 1} / {totalStages}
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{t(stage.descriptionKey)}
</p>
<div className="flex items-center gap-2">
{Array.from({ length: totalStages }).map((_, index) => (
<div
key={index}
className={`h-1 flex-1 rounded-full transition-colors ${
index === currentStage ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
{totalStages > 1 && (
<div className="flex justify-end gap-2 pt-2">
{currentStage > 0 && (
<button
onClick={handlePrevious}
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{t('showcases.buttons.previous')}
</button>
)}
<button
onClick={handleNext}
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 border border-foreground transition-colors"
>
{currentStage === totalStages - 1
? t('showcases.buttons.finish')
: t('showcases.buttons.next')}
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</DialogContent>
</Dialog>
);
}
);
export const FeatureShowcaseDialog = defineModal<
FeatureShowcaseDialogProps,
void
>(FeatureShowcaseDialogImpl);

View File

@@ -1,179 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, ChevronLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { useKeyExit, Scope } from '@/keyboard';
import { ShowcaseStageMedia } from './ShowcaseStageMedia';
import type { ShowcaseConfig } from '@/types/showcase';
interface FeatureShowcaseModalProps {
isOpen: boolean;
onClose: () => void;
config: ShowcaseConfig;
}
/**
* FeatureShowcaseModal - Generic multi-stage modal for showcasing features with media
*
* Displays a bottom-aligned modal with stages containing videos or images, title, description,
* and navigation controls. Properly manages keyboard shortcuts (ESC captured but disabled)
* and scopes to prevent closing underlying features.
*
* Features:
* - Multi-stage or single-stage support (hides navigation if 1 stage)
* - Video support with loading states and progress bars
* - Image support with loading skeleton
* - Responsive design (full-width on mobile, 2/3 width on desktop)
* - i18n support via translation keys
* - Smooth transitions between stages
*
* @param isOpen - Controls modal visibility
* @param onClose - Called when user finishes the showcase (via Finish button on last stage)
* @param config - ShowcaseConfig object defining stages, media, and translation keys
*/
export function FeatureShowcaseModal({
isOpen,
onClose,
config,
}: FeatureShowcaseModalProps) {
const [currentStage, setCurrentStage] = useState(0);
const { t } = useTranslation('tasks');
const { enableScope, disableScope, activeScopes } = useHotkeysContext();
const previousScopesRef = useRef<string[]>([]);
const stage = config.stages[currentStage];
const totalStages = config.stages.length;
/**
* Scope management for keyboard shortcuts:
* When showcase opens, we capture all currently active scopes, disable them,
* and enable only DIALOG scope. This ensures ESC key presses are captured by
* our showcase handler (which does nothing) instead of triggering underlying
* close handlers. When closing, we restore the original scopes.
*/
useEffect(() => {
if (isOpen) {
previousScopesRef.current = activeScopes;
activeScopes.forEach((scope) => disableScope(scope));
enableScope(Scope.DIALOG);
} else {
disableScope(Scope.DIALOG);
previousScopesRef.current.forEach((scope) => enableScope(scope));
}
return () => {
disableScope(Scope.DIALOG);
previousScopesRef.current.forEach((scope) => enableScope(scope));
};
// activeScopes intentionally omitted - we only capture on open, not on every scope change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, enableScope, disableScope]);
useKeyExit(
(e) => {
e?.preventDefault();
},
{ scope: Scope.DIALOG, enabled: isOpen }
);
const handleNext = () => {
setCurrentStage((prev) => {
if (prev >= totalStages - 1) {
onClose();
return prev;
}
return prev + 1;
});
};
const handlePrevious = () => {
setCurrentStage((prev) => Math.max(prev - 1, 0));
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/50 z-[9998]"
/>
<motion.div
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 100 }}
transition={{ duration: 0.3, ease: [0.2, 0, 0, 1] }}
className="fixed bottom-4 left-0 right-0 mx-4 w-auto xl:bottom-8 xl:left-0 xl:right-0 xl:mx-auto xl:w-full xl:max-w-[min(66.66vw,calc((100svh-20rem)*1.6))] bg-card border border-border rounded-lg shadow-2xl overflow-hidden z-[9999]"
>
<AnimatePresence mode="wait">
<motion.div
key={currentStage}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
>
<ShowcaseStageMedia media={stage.media} />
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground">
{t(stage.titleKey)}
</h3>
</div>
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
{currentStage + 1} / {totalStages}
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{t(stage.descriptionKey)}
</p>
<div className="flex items-center gap-2">
{Array.from({ length: totalStages }).map((_, index) => (
<div
key={index}
className={`h-1 flex-1 rounded-full transition-colors ${
index === currentStage ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
{totalStages > 1 && (
<div className="flex justify-end gap-2 pt-2">
{currentStage > 0 && (
<button
onClick={handlePrevious}
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{t('showcases.buttons.previous')}
</button>
)}
<button
onClick={handleNext}
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 border border-foreground transition-colors"
>
{currentStage === totalStages - 1
? t('showcases.buttons.finish')
: t('showcases.buttons.next')}
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -1,45 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useUserSystem } from '@/components/config-provider';
export interface ShowcasePersistence {
hasSeen: (id: string) => boolean;
markSeen: (id: string) => Promise<void>;
isLoaded: boolean;
}
export function useShowcasePersistence(): ShowcasePersistence {
const { config, updateAndSaveConfig, loading } = useUserSystem();
const seenFeatures = useMemo(
() => config?.showcases?.seen_features ?? [],
[config?.showcases?.seen_features]
);
const hasSeen = useCallback(
(id: string): boolean => {
return seenFeatures.includes(id);
},
[seenFeatures]
);
const markSeen = useCallback(
async (id: string): Promise<void> => {
if (seenFeatures.includes(id)) {
return;
}
await updateAndSaveConfig({
showcases: {
seen_features: [...seenFeatures, id],
},
});
},
[seenFeatures, updateAndSaveConfig]
);
return {
hasSeen,
markSeen,
isLoaded: !loading,
};
}

View File

@@ -1,111 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ShowcaseConfig } from '@/types/showcase';
import { useShowcasePersistence } from './useShowcasePersistence';
export interface ShowcaseTriggerOptions {
enabled: boolean;
openDelay?: number;
resetOnDisable?: boolean;
markSeenOnClose?: boolean;
}
export interface ShowcaseTriggerResult {
isOpen: boolean;
open: () => void;
close: () => void;
hasSeen: boolean;
}
export function useShowcaseTrigger(
config: ShowcaseConfig,
options: ShowcaseTriggerOptions
): ShowcaseTriggerResult {
const {
enabled,
openDelay = 300,
resetOnDisable = true,
markSeenOnClose = true,
} = options;
const persistence = useShowcasePersistence();
const [isOpen, setIsOpen] = useState(false);
const [hasSeenState, setHasSeenState] = useState(false);
const timerRef = useRef<number | null>(null);
const mountedRef = useRef(true);
// Keep 'hasSeenState' in sync if id change or config loads
useEffect(() => {
if (!persistence.isLoaded) return;
setHasSeenState(persistence.hasSeen(config.id));
}, [persistence.isLoaded, config.id, persistence]);
// Cleanup timers
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, []);
// Handle enabled state changes
useEffect(() => {
if (!persistence.isLoaded) return;
if (enabled) {
// Only show if not seen
if (!hasSeenState) {
// Clear any existing timer
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
// Delay opening to ensure UI is mounted
timerRef.current = window.setTimeout(() => {
if (mountedRef.current) {
setIsOpen(true);
timerRef.current = null;
}
}, openDelay);
}
} else {
// Reset when disabled (if configured)
if (resetOnDisable) {
// Clear pending timer
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setIsOpen(false);
}
}
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [persistence.isLoaded, enabled, hasSeenState, openDelay, resetOnDisable]);
const open = useCallback(() => {
setIsOpen(true);
}, []);
const close = useCallback(() => {
if (markSeenOnClose) {
persistence.markSeen(config.id);
setHasSeenState(true);
}
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setIsOpen(false);
}, [config.id, markSeenOnClose, persistence]);
return { isOpen, open, close, hasSeen: hasSeenState };
}

View File

@@ -8,9 +8,9 @@ import { Loader } from '@/components/ui/loader';
import { tasksApi } from '@/lib/api';
import type { GitBranch, TaskAttempt, BranchStatus } from 'shared/types';
import { openTaskForm } from '@/lib/openTaskForm';
import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal';
import { FeatureShowcaseDialog } from '@/components/dialogs/global/FeatureShowcaseDialog';
import { showcases } from '@/config/showcases';
import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger';
import { useUserSystem } from '@/components/config-provider';
import { usePostHog } from 'posthog-js/react';
import { useSearch } from '@/contexts/search-context';
@@ -201,10 +201,31 @@ export function ProjectTasks() {
const isSharedPanelOpen = Boolean(selectedSharedTask);
const isPanelOpen = isTaskPanelOpen || isSharedPanelOpen;
const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } =
useShowcaseTrigger(showcases.taskPanel, {
enabled: isPanelOpen,
const { config, updateAndSaveConfig, loading } = useUserSystem();
const isLoaded = !loading;
const showcaseId = showcases.taskPanel.id;
const seenFeatures = config?.showcases?.seen_features ?? [];
const seen = isLoaded && seenFeatures.includes(showcaseId);
useEffect(() => {
if (!isLoaded || !isPanelOpen || seen) return;
FeatureShowcaseDialog.show({ config: showcases.taskPanel }).finally(() => {
FeatureShowcaseDialog.hide();
if (seenFeatures.includes(showcaseId)) return;
void updateAndSaveConfig({
showcases: { seen_features: [...seenFeatures, showcaseId] },
});
});
}, [
isLoaded,
isPanelOpen,
seen,
showcaseId,
updateAndSaveConfig,
seenFeatures,
]);
const isLatest = attemptId === 'latest';
const { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts(
@@ -1049,11 +1070,6 @@ export function ProjectTasks() {
)}
<div className="flex-1 min-h-0">{attemptArea}</div>
<FeatureShowcaseModal
isOpen={showTaskPanelShowcase}
onClose={closeTaskPanelShowcase}
config={showcases.taskPanel}
/>
</div>
);
}