feature showcase (vibe-kanban) (#1042)

* Done! I've created a floating onboarding panel that:

1. **Anchors to the resize handle** - Uses `#handle-kr` (the drag handle between kanban and task panel) as an anchor point
2. **Floats to the left** - Positioned with fixed positioning, offset from the handle's right edge
3. **Tracks panel movement** - Uses MutationObserver and resize listeners to stay anchored even when the panel is resized
4. **Animates in/out** - Smooth fade and slide transitions with framer-motion
5. **Shows for all tasks** - Displays whenever `isPanelOpen` is true (desktop only)

The component includes placeholder content with a welcome message, step indicators (1 of 3), and Next/Skip buttons ready for your multi-stage guide implementation.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Perfect! I've updated the onboarding component with a multi-stage system featuring 5 sections:

**Features:**
- **5 stages**: Follow-ups, Code Review, Git Actions, Live Previews, and VK Companion
- **Video support**: Each stage displays the same video (for now) with autoplay and loop
- **Navigation**: Previous/Next buttons, with "Finish" on the last stage
- **Progress indicators**: Dots and "Step X of 5" counter
- **Smooth transitions**: Content slides between stages using framer-motion
- **Skip option**: Available on all stages

Each stage has a descriptive title and two-sentence description explaining the feature. The videos automatically reload and play when switching between stages.

* Increased the panel width from 384px (w-96) to 576px (w-[36rem]). The video should now be significantly larger.

* Fixed! The positioning now:
- Checks the panel height and viewport bounds
- Keeps 20px margin from top and bottom edges
- Adjusts the anchor position if the panel would overflow
- Re-calculates when stage changes (since content height may vary)

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! Now the panel:
- Checks if it would overflow at top or bottom when centered
- Switches to top-aligned positioning when it would overflow
- Calculates a safe top position that keeps the entire panel visible
- Uses `translateY(0)` instead of `translateY(-50%)` when top-aligned

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! I simplified the positioning logic:
- Centers the panel vertically on the handle position
- No transform needed - uses absolute top positioning
- Ensures 20px margins from viewport edges
- Panel stays fully visible when it would overflow

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! Now it properly centers the panel on the middle of the handle by using `handleRect.top + handleRect.height / 2` as the center point, then offsetting by half the panel height.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Done! Converted to a modal with:
- **2/3 screen width** centered horizontally
- **Bottom aligned** with 2rem margin from bottom
- **Backdrop overlay** that doesn't dismiss on click
- **No ESC dismiss** (removed keyboard handler)
- Smooth slide-up animation from bottom

* Fixed! The issue was that framer-motion's `y` transform was overriding the Tailwind `-translate-x-1/2`. Now using inline style for `translateX(-50%)` which works alongside motion's `y` transform.

* Perfect! Now the modal is horizontally centered using `left-[16.66%]` (which is 100% - 66.66% divided by 2) and `w-2/3`, which means it takes up 2/3 of the width and is centered. This won't conflict with framer-motion's transform animations for the y-axis.

* Done! I've made the following changes:

1. **Removed Skip and Close buttons** - Users can just click "Next" through all stages or "Finish" on the last one
2. **Improved step count visual hierarchy:**
   - Moved the numeric count (1 / 5) to the top right next to the title
   - Made it more prominent with `font-medium` styling
   - Changed the dots to full-width progress bars that stretch across the bottom
   - Progress bars use `h-1` height and `flex-1` to distribute evenly
   - Cleaner, more modern progress indicator

The layout now has better visual hierarchy with the count at the top and a progress bar below the description.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! I've added `aspect-video` to the video container, which reserves a 16:9 aspect ratio space before the video loads. The video now uses `h-full object-cover` to fill that space. This prevents layout shift during the entrance animation.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Perfect! I've updated the buttons to match the app's theme:

1. **Added icons**: `ChevronLeft` for Previous and `ChevronRight` for Next
2. **Removed border radius**: No `rounded` class
3. **Outline border**: Using `border border-input` for Previous and `border border-foreground` for Next
4. **Consistent sizing**: Using `h-10 px-4 py-2` matching the default button size
5. **Proper styling**: Following the button variants from the app's button component

The buttons now match the visual style used throughout the rest of the application.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* ## Summary

I've successfully implemented a **generic feature showcase system** following delegation-oriented development:

###  Completed Components

1. **Type System** - `frontend/src/types/showcase.ts`
2. **Configuration** - `frontend/src/config/showcases.ts` (task panel showcase with 5 stages)
3. **Persistence** - `frontend/src/utils/showcasePersistence.ts` (localStorage-based)
4. **Video Progress Hook** - `frontend/src/hooks/useVideoProgress.ts` (tracks loading/buffering/playback)
5. **Media Component** - `frontend/src/components/showcase/ShowcaseStageMedia.tsx` (images/videos with loading states)
6. **Main Modal** - `frontend/src/components/showcase/FeatureShowcaseModal.tsx` (with ESC key handling)
7. **i18n Keys** - Added to `tasks.json`
8. **Integration** - Replaced TaskPanelOnboarding in project-tasks.tsx

### 🎯 Key Features

-  Supports images and videos with loading indicators
-  Video progress bar (buffered + played, like vibe-kanban-website)
-  ESC key properly scoped (closes modal, not task panel)
-  LocalStorage persistence (`showcase:<id>:v<version>:seen`)
-  i18n support for all content
-  Single-stage support (hides nav if 1 stage)
-  Flexible trigger location (configured per showcase)
-  All TypeScript and Rust checks pass

### 🧪 Ready for Browser Testing

What port is your dev server running on? I'll test:
1. Opening a task triggers the showcase
2. Video loading states and progress bars
3. Stage navigation (Previous/Next/Finish)
4. ESC closes only modal, not task panel
5. Persistence (doesn't show again after Finish)

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* ##  Feature Showcase System - Complete!

Successfully implemented a **generic, reusable feature showcase system** following delegation-oriented development with full QA verification.

### 📦 Deliverables

**Core Components:**
- `FeatureShowcaseModal` - Main modal with ESC key scope management
- `ShowcaseStageMedia` - Image/video renderer with loading states & progress bars
- `useVideoProgress` - Hook for video buffering/playback tracking
- `showcasePersistence` - LocalStorage helpers for seen tracking
- Type definitions & configuration system

**Integration:**
- Migrated task panel onboarding to new system
- Added i18n keys for all showcase content
- Removed old `TaskPanelOnboarding` component

###  Features

 **Flexible Triggers** - Can be placed anywhere (currently on task panel open)
 **ESC Key Handling** - Proper scope management (closes modal, not underlying feature)
 **Media Support** - Images and videos with loading indicators
 **Video Progress** - Thin progress bar showing buffered/played state
 **Persistence** - LocalStorage tracking with version support
 **i18n Ready** - Full localization support
 **Single/Multi-stage** - Adapts UI for 1 or multiple stages
 **Mobile-aware** - Only shows on desktop (width ≥ 1280px)

### 🧪 QA Results

All tests passed including:
- Showcase triggering on task open
- Navigation between 5 stages
- ESC key scope handling
- Persistence across sessions
- Responsive behavior
- Edge case handling

### 🎯 Usage

To add a new showcase:
1. Define config in `showcases.ts`
2. Add i18n keys in `tasks.json`
3. Trigger where appropriate with `hasSeen()` check
4. Render `<FeatureShowcaseModal>` with config

* Done! Removed backdrop click to close functionality. Users can only close the showcase by:
- Clicking "Finish" button on the last stage
- Pressing ESC key

* Fixed! The showcase now:
- Shows on **all screen sizes** (mobile and desktop)
- **Mobile**: Full width with 1rem horizontal padding
- **Desktop (xl+)**: 2/3 width, centered
- Bottom spacing adjusts responsively (1rem mobile, 2rem desktop)

* update task text

* Done! ESC is now captured but does nothing. Users must click through the stages or use "Finish" to close the showcase.

* update task text

* i18n (vibe-kanban 7f62c07e)

run scripts/check-i18n.sh and resolve any discrepancies by translating missing values.

* Done! All changes complete:

 **Mobile support** - Removed mobile check, now shows on all devices with responsive width
 **Component docs** - Added JSDoc to both FeatureShowcaseModal and ShowcaseStageMedia
 **Progress bar** - Increased height from 2px to 4px for better visibility
 **Developer guide** - Created comprehensive README.md in `components/showcase/`

The showcase now works across all screen sizes and has full documentation for future development.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* Fixed! Removed the incorrect ESC test from the checklist. The showcase can only be closed by clicking "Finish" on the last stage (ESC is captured but disabled).

* Fixed! Removed that line and also corrected "after closing" to "after finishing" to be accurate.

* Fixed! Now all button labels use i18n keys:
- `showcases.buttons.previous`
- `showcases.buttons.next`
- `showcases.buttons.finish`

* Perfect! All review comments addressed:

##  Changes Complete

1. **Scope handling documentation** - Added detailed comment explaining why we enable/disable scopes
2. **Created `useShowcaseTrigger` hook** - Extracted and simplified the trigger logic
3. **Refactored project-tasks.tsx** - Now uses the hook (much cleaner)
4. **Updated README** - Reflects the new hook-based usage pattern

The code is now more robust and maintainable. All TypeScript and Rust checks pass. The showcase system is ready to be easily reused across any feature in the app.

* Cleanup script changes for task attempt 5c2c52b0-90de-4f9a-98a2-cade3e471c4f

* video showcase bug (vibe-kanban 383590da)

CHANGED FILES:
frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/README.md
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/hooks/useVideoProgress.ts
frontend/src/i18n/locales/en/tasks.json
frontend/src/i18n/locales/es/tasks.json
frontend/src/i18n/locales/ja/tasks.json
frontend/src/i18n/locales/ko/tasks.json
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

The showcase is sometimes being shown more than once. Users press the finish button, the showcase closes, then they click on another task and it opens again.

Use the oracle to extensively diagnose this issue then resolve it.

* showcase improvement 2 (vibe-kanban 3e5a7c01)

frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/README.md
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/hooks/useVideoProgress.ts
frontend/src/i18n/locales/en/tasks.json
frontend/src/i18n/locales/es/tasks.json
frontend/src/i18n/locales/ja/tasks.json
frontend/src/i18n/locales/ko/tasks.json
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

Do we need semantic versioning? We can just use a single number right (1, 2, ...). We aren't ever parsing the sem var and doing comparisons right? Or am i mistaken?

* showcase improvement 1 (vibe-kanban be7d377b)

see the first useEffect in FeatureShowcaseModal
Isn't this too specific to the scopes featured in the task panel? If we want to show this showcase elsewhere, it might change.

* remove unused clearSeen() function

* feature showcase | no auto replay (vibe-kanban 2e945b1f)

frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/hooks/useVideoProgress.ts
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

Each video should only play once, then once finished, a replay button should appear in the center.

* updated videos

* feature showcase | prevent cut off (vibe-kanban 79364905)

frontend/src/components/showcase/FeatureShowcaseModal.tsx
frontend/src/components/showcase/ShowcaseStageMedia.tsx
frontend/src/config/showcases.ts
frontend/src/hooks/useShowcaseTrigger.ts
frontend/src/pages/project-tasks.tsx
frontend/src/types/showcase.ts
frontend/src/utils/showcasePersistence.ts

On some displays, the showcase pop up height is too large. Use the oracle to hypothesize various solutions to this then implement the most promising one.

* update showcase videos

* remove showcase readme
This commit is contained in:
Britannio Jarrett
2025-10-17 15:46:21 +01:00
committed by GitHub
parent 362e3e23b4
commit c48ba45573
16 changed files with 661 additions and 3 deletions

View File

@@ -0,0 +1,179 @@
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 = () => {
if (currentStage < totalStages - 1) {
setCurrentStage((prev) => prev + 1);
} else {
onClose();
}
};
const handlePrevious = () => {
if (currentStage > 0) {
setCurrentStage((prev) => prev - 1);
}
};
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

@@ -0,0 +1,98 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from '@/components/ui/loader';
import { useVideoProgress } from '@/hooks/useVideoProgress';
import type { ShowcaseMedia } from '@/types/showcase';
import { RefreshCw } from 'lucide-react';
interface ShowcaseStageMediaProps {
media: ShowcaseMedia;
}
/**
* ShowcaseStageMedia - Renders media (images or videos) for showcase stages
*
* Handles different media types with appropriate loading states:
* - Videos: Shows loading spinner, autoplay once, and thin progress bar
* displaying both buffered (light) and played (primary) progress
* - Images: Shows loading skeleton until image loads
*
* Uses fixed aspect ratio (16:10) to prevent layout shift during loading.
*
* @param media - ShowcaseMedia object with type ('image' or 'video') and src URL
*/
export function ShowcaseStageMedia({ media }: ShowcaseStageMediaProps) {
const { t } = useTranslation('common');
const videoRef = useRef<HTMLVideoElement>(null);
const { isLoading, playedPercent, bufferedPercent } =
useVideoProgress(videoRef);
const [imageLoaded, setImageLoaded] = useState(false);
const [videoEnded, setVideoEnded] = useState(false);
if (media.type === 'video') {
const handleReplay = () => {
if (videoRef.current) {
videoRef.current.currentTime = 0;
videoRef.current.play();
setVideoEnded(false);
}
};
return (
<div className="relative w-full aspect-[16/10] bg-black">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader size={32} />
</div>
)}
<video
ref={videoRef}
src={media.src}
poster={media.poster}
autoPlay
muted
playsInline
onEnded={() => setVideoEnded(true)}
className="w-full h-full object-contain"
/>
{videoEnded && (
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={handleReplay}
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<RefreshCw size={20} />
{t('buttons.replay')}
</button>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-transparent">
<div
className="h-1 bg-muted-foreground/30 transition-all"
style={{ width: `${bufferedPercent}%` }}
/>
<div
className="absolute top-0 left-0 h-1 bg-primary transition-all"
style={{ width: `${playedPercent}%` }}
/>
</div>
</div>
);
}
return (
<div className="relative w-full aspect-[16/10] bg-muted">
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader size={32} />
</div>
)}
<img
src={media.src}
alt={media.alt || ''}
onLoad={() => setImageLoaded(true)}
className="w-full h-full object-contain"
/>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { ShowcaseConfig } from '@/types/showcase';
export const taskPanelShowcase: ShowcaseConfig = {
id: 'task-panel-onboarding',
version: 1,
stages: [
{
titleKey: 'showcases.taskPanel.companion.title',
descriptionKey: 'showcases.taskPanel.companion.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-companion-demo-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.installation.title',
descriptionKey: 'showcases.taskPanel.installation.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-install-companion-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.codeReview.title',
descriptionKey: 'showcases.taskPanel.codeReview.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-code-review-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.pullRequest.title',
descriptionKey: 'showcases.taskPanel.pullRequest.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-git-pr-3.mp4',
},
},
],
};
export const showcases = {
taskPanel: taskPanelShowcase,
};

View File

@@ -0,0 +1,109 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ShowcaseConfig } from '@/types/showcase';
import { hasSeen as hasSeenUtil, markSeen } from '@/utils/showcasePersistence';
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 [isOpen, setIsOpen] = useState(false);
const [hasSeen, setHasSeen] = useState(() =>
hasSeenUtil(config.id, config.version)
);
const timerRef = useRef<number | null>(null);
const mountedRef = useRef(true);
// Keep 'hasSeen' in sync if id/version change
useEffect(() => {
setHasSeen(hasSeenUtil(config.id, config.version));
}, [config.id, config.version]);
// 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 (enabled) {
// Only show if not seen
if (!hasSeen) {
// 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;
}
};
}, [enabled, hasSeen, openDelay, resetOnDisable]);
const open = useCallback(() => {
setIsOpen(true);
}, []);
const close = useCallback(() => {
if (markSeenOnClose) {
markSeen(config.id, config.version);
setHasSeen(true);
}
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setIsOpen(false);
}, [config.id, config.version, markSeenOnClose]);
return { isOpen, open, close, hasSeen };
}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState, RefObject } from 'react';
interface VideoProgress {
isLoading: boolean;
playedPercent: number; // 0-100
bufferedPercent: number; // 0-100
duration: number; // in seconds
}
/**
* Track video loading state and playback/buffering progress
*/
export function useVideoProgress(
videoRef: RefObject<HTMLVideoElement>
): VideoProgress {
const [isLoading, setIsLoading] = useState(true);
const [playedPercent, setPlayedPercent] = useState(0);
const [bufferedPercent, setBufferedPercent] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Event handlers
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handleCanPlay = () => {
setIsLoading(false);
};
const handleWaiting = () => {
setIsLoading(true);
};
const handleTimeUpdate = () => {
if (video.duration) {
setPlayedPercent((video.currentTime / video.duration) * 100);
}
};
const handleProgress = () => {
if (video.duration && video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
setBufferedPercent((bufferedEnd / video.duration) * 100);
}
};
// Attach listeners
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('progress', handleProgress);
return () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('progress', handleProgress);
};
}, [videoRef]);
return { isLoading, playedPercent, bufferedPercent, duration };
}

View File

@@ -11,7 +11,8 @@
"manage": "Manage",
"connect": "Connect",
"disconnect": "Disconnect",
"close": "Close"
"close": "Close",
"replay": "Replay"
},
"states": {
"loading": "Loading...",

View File

@@ -240,5 +240,30 @@
"createSubtask": "Create subtask",
"task": "Task",
"duplicate": "Duplicate"
},
"showcases": {
"taskPanel": {
"companion": {
"title": "Vibe Kanban Companion Point-and-Click",
"description": "Click any UI component in the preview window to select it precisely. The coding agent receives exact DOM selectors and component hierarchy, eliminating ambiguous feedback."
},
"installation": {
"title": "Dev Server & Companion Installation",
"description": "Set a dev server command for previews, with a setup script configurable in settings. Use a coding agent to automatically install the Vibe Kanban web companion."
},
"codeReview": {
"title": "Built-in Code Review",
"description": "Add line-specific comments directly in the diffs view with the plus icon. All feedback is collected and sent as a complete review to the coding agent."
},
"pullRequest": {
"title": "Create PR from Task",
"description": "Merge your changes or create pull requests directly from task attempts. The PR dialog pre-fills title and description from your task details for streamlined workflows."
}
},
"buttons": {
"previous": "Previous",
"next": "Next",
"finish": "Finish"
}
}
}

View File

@@ -22,6 +22,7 @@
"disconnect": "Desconectar",
"edit": "Editar",
"manage": "Gestionar",
"replay": "Reproducir",
"reset": "Restablecer",
"save": "Guardar",
"send": "Enviar"

View File

@@ -240,5 +240,30 @@
},
"viewProcessesDialog": {
"title": "Execution processes"
},
"showcases": {
"taskPanel": {
"companion": {
"title": "Vibe Kanban Companion: Apuntar y Hacer Clic",
"description": "Haz clic en cualquier componente de la interfaz en la ventana de vista previa para seleccionarlo con precisión. El agente de codificación recibe selectores DOM exactos y jerarquía de componentes, eliminando comentarios ambiguos."
},
"installation": {
"title": "Instalación del Servidor de Desarrollo y Companion",
"description": "Configura un comando de servidor de desarrollo para vistas previas, con un script de configuración configurable en ajustes. Usa un agente de codificación para instalar automáticamente el companion web de Vibe Kanban."
},
"codeReview": {
"title": "Revisión de Código Integrada",
"description": "Agrega comentarios específicos de línea directamente en la vista de diferencias con el icono de más. Todos los comentarios se recopilan y envían como una revisión completa al agente de codificación."
},
"pullRequest": {
"title": "Crear PR desde Tarea",
"description": "Fusiona tus cambios o crea pull requests directamente desde los intentos de tarea. El diálogo de PR rellena previamente el título y la descripción desde los detalles de tu tarea para flujos de trabajo optimizados."
}
},
"buttons": {
"previous": "Anterior",
"next": "Siguiente",
"finish": "Finalizar"
}
}
}

View File

@@ -22,6 +22,7 @@
"disconnect": "切断",
"edit": "編集",
"manage": "管理",
"replay": "再生",
"reset": "リセット",
"save": "保存",
"send": "送信"

View File

@@ -240,5 +240,30 @@
},
"viewProcessesDialog": {
"title": "Execution processes"
},
"showcases": {
"taskPanel": {
"companion": {
"title": "Vibe Kanban Companion ポイント&クリック",
"description": "プレビューウィンドウ内の任意のUIコンポーネントをクリックして正確に選択できます。コーディングエージェントは正確なDOMセレクタとコンポーネント階層を受け取り、曖昧なフィードバックを排除します。"
},
"installation": {
"title": "開発サーバーとCompanionのインストール",
"description": "プレビュー用に開発サーバーコマンドを設定します。設定でセットアップスクリプトを構成できます。コーディングエージェントを使用してVibe Kanban Webコンパニオンを自動的にインストールします。"
},
"codeReview": {
"title": "組み込みコードレビュー",
"description": "プラスアイコンを使用して、差分ビューに直接行固有のコメントを追加します。すべてのフィードバックが収集され、完全なレビューとしてコーディングエージェントに送信されます。"
},
"pullRequest": {
"title": "タスクからPRを作成",
"description": "タスク試行から直接変更をマージしたり、プルリクエストを作成できます。PRダイアログはタスク詳細からタイトルと説明を事前入力し、ワークフローを効率化します。"
}
},
"buttons": {
"previous": "前へ",
"next": "次へ",
"finish": "完了"
}
}
}

View File

@@ -22,6 +22,7 @@
"disconnect": "연결 해제",
"edit": "편집",
"manage": "관리",
"replay": "다시 재생",
"reset": "초기화",
"save": "저장",
"send": "보내기"

View File

@@ -240,5 +240,30 @@
},
"viewProcessesDialog": {
"title": "Execution processes"
},
"showcases": {
"taskPanel": {
"companion": {
"title": "Vibe Kanban Companion 포인트 앤 클릭",
"description": "미리보기 창에서 UI 구성 요소를 클릭하여 정확하게 선택하세요. 코딩 에이전트는 정확한 DOM 선택자와 구성 요소 계층을 수신하여 모호한 피드백을 제거합니다."
},
"installation": {
"title": "개발 서버 및 Companion 설치",
"description": "미리보기를 위한 개발 서버 명령을 설정하고 설정에서 구성 가능한 설정 스크립트를 사용하세요. 코딩 에이전트를 사용하여 Vibe Kanban 웹 컴패니언을 자동으로 설치합니다."
},
"codeReview": {
"title": "내장 코드 리뷰",
"description": "더하기 아이콘을 사용하여 차이점 보기에서 직접 줄별 주석을 추가하세요. 모든 피드백이 수집되어 완전한 리뷰로 코딩 에이전트에게 전송됩니다."
},
"pullRequest": {
"title": "작업에서 PR 생성",
"description": "작업 시도에서 직접 변경사항을 병합하거나 풀 리퀘스트를 생성하세요. PR 대화상자는 작업 세부정보에서 제목과 설명을 미리 채워 워크플로를 간소화합니다."
}
},
"buttons": {
"previous": "이전",
"next": "다음",
"finish": "완료"
}
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
@@ -6,9 +6,11 @@ import { Card, CardContent } from '@/components/ui/card';
import { AlertTriangle, Plus } from 'lucide-react';
import { Loader } from '@/components/ui/loader';
import { tasksApi } from '@/lib/api';
import { useState } from 'react';
import type { GitBranch } from 'shared/types';
import { openTaskForm } from '@/lib/openTaskForm';
import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal';
import { taskPanelShowcase } from '@/config/showcases';
import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger';
import { useSearch } from '@/contexts/search-context';
import { useProject } from '@/contexts/project-context';
@@ -154,6 +156,11 @@ export function ProjectTasks() {
const isPanelOpen = Boolean(taskId && selectedTask);
const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } =
useShowcaseTrigger(taskPanelShowcase, {
enabled: isPanelOpen,
});
const isLatest = attemptId === 'latest';
const { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts(
taskId,
@@ -754,6 +761,11 @@ export function ProjectTasks() {
)}
<div className="flex-1 min-h-0">{attemptArea}</div>
<FeatureShowcaseModal
isOpen={showTaskPanelShowcase}
onClose={closeTaskPanelShowcase}
config={taskPanelShowcase}
/>
</div>
);
}

View File

@@ -0,0 +1,18 @@
export interface ShowcaseMedia {
type: 'image' | 'video';
src: string;
poster?: string;
alt?: string;
}
export interface ShowcaseStage {
titleKey: string;
descriptionKey: string;
media: ShowcaseMedia;
}
export interface ShowcaseConfig {
id: string;
version: number;
stages: ShowcaseStage[];
}

View File

@@ -0,0 +1,26 @@
/**
* Check if a user has seen a specific showcase version
*
* @param id - Unique identifier for the showcase
* @param version - Version number for the showcase
* @returns true if the user has seen this showcase version
*
* Storage key format: `showcase:{id}:v{version}:seen`
*/
export function hasSeen(id: string, version: number): boolean {
const key = `showcase:${id}:v${version}:seen`;
return localStorage.getItem(key) === 'true';
}
/**
* Mark a showcase as seen
*
* @param id - Unique identifier for the showcase
* @param version - Version number for the showcase
*
* Storage key format: `showcase:{id}:v{version}:seen`
*/
export function markSeen(id: string, version: number): void {
const key = `showcase:${id}:v${version}:seen`;
localStorage.setItem(key, 'true');
}