Skip to content

Commit d75c214

Browse files
committed
feat: smoother suggested-tasks list animation
- page navigation slides horizontally with mode="wait" so the panel no longer jumps from two pages briefly stacking in the layout - dropped overflow-hidden so sliding rows are no longer clipped at the edges - card dismiss scales down and fades with a synced layout shift - center the task composer and anchor the suggestions panel below it - add a dev-only suggestion simulation harness gated by VITE_SIMULATE_SUGGESTIONS / a localStorage flag
1 parent 4b527ba commit d75c214

7 files changed

Lines changed: 147 additions & 47 deletions

File tree

apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { RENDERER_TOKENS } from "@renderer/di/tokens";
55
import { useActiveRepoStore } from "@stores/activeRepoStore";
66
import { useEffect } from "react";
77

8+
const SIMULATE_SUGGESTIONS_STORAGE_KEY = "posthog-code:simulate-suggestions";
9+
10+
function shouldSimulateSuggestions(): boolean {
11+
if (!import.meta.env.DEV) return false;
12+
if (import.meta.env.VITE_SIMULATE_SUGGESTIONS === "1") return true;
13+
return window.localStorage.getItem(SIMULATE_SUGGESTIONS_STORAGE_KEY) === "1";
14+
}
15+
816
export function useSetupDiscovery() {
917
const selectedDirectory = useActiveRepoStore((s) => s.path);
1018

@@ -16,6 +24,11 @@ export function useSetupDiscovery() {
1624
useEffect(() => {
1725
if (!selectedDirectory) return;
1826
const service = get<SetupRunService>(RENDERER_TOKENS.SetupRunService);
27+
if (shouldSimulateSuggestions()) {
28+
service.startSuggestionSimulation(selectedDirectory);
29+
return;
30+
}
31+
1932
const discoveryEverStarted = Object.values(
2033
useSetupStore.getState().discoveryByRepo,
2134
).some((d) => d.status !== "idle");

apps/code/src/renderer/features/setup/services/setupRunService.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,42 @@ const log = logger.scope("setup-run-service");
2828

2929
let activityIdCounter = 0;
3030

31+
const SIMULATED_SUGGESTION_CATEGORIES: DiscoveredTask["category"][] = [
32+
"bug",
33+
"performance",
34+
"dead_code",
35+
"duplication",
36+
"event_tracking",
37+
"experiment",
38+
];
39+
40+
const SIMULATED_SUGGESTION_INTERVAL_MS = 2500;
41+
42+
function buildSimulatedSuggestion(
43+
repoPath: string,
44+
index: number,
45+
): DiscoveredTask {
46+
const category =
47+
SIMULATED_SUGGESTION_CATEGORIES[
48+
index % SIMULATED_SUGGESTION_CATEGORIES.length
49+
];
50+
51+
return {
52+
id: `simulated-suggestion-${index}`,
53+
source: "agent",
54+
repoPath,
55+
category,
56+
title: `Simulated suggestion ${index + 1}`,
57+
description:
58+
"A generated setup suggestion for exercising empty-state layout stability while new content keeps arriving.",
59+
impact:
60+
"This is dev-only test data for checking whether the composer and suggestion rows stay anchored during incremental updates.",
61+
recommendation:
62+
"Use this simulation to hover and click suggestions while the list keeps changing underneath the same UI surface.",
63+
prompt: `Investigate simulated suggestion ${index + 1}`,
64+
};
65+
}
66+
3167
function extractPathFromRawInput(
3268
tool: string,
3369
rawInput: Record<string, unknown> | undefined,
@@ -236,6 +272,9 @@ export class SetupRunService {
236272
private anyDiscoveryEverLaunched = false;
237273
private discoveryStartingByRepo = new Set<string>();
238274
private enricherSuggestionsRunningByRepo = new Set<string>();
275+
private simulatedSuggestionsRepo: string | null = null;
276+
private simulatedSuggestionsTimer: number | null = null;
277+
private simulatedSuggestionsCount = 0;
239278

240279
startSetup(directory: string): void {
241280
// Defense in depth: never auto-run from a non-idle persisted state.
@@ -256,6 +295,36 @@ export class SetupRunService {
256295
this.injectEnricherSuggestions(directory);
257296
}
258297

298+
startSuggestionSimulation(directory: string): void {
299+
if (!directory) return;
300+
if (this.simulatedSuggestionsRepo === directory) return;
301+
302+
if (this.simulatedSuggestionsTimer !== null) {
303+
window.clearInterval(this.simulatedSuggestionsTimer);
304+
}
305+
306+
this.simulatedSuggestionsRepo = directory;
307+
this.simulatedSuggestionsCount = 0;
308+
useSetupStore
309+
.getState()
310+
.startDiscovery(directory, "simulated-discovery", "simulated-run");
311+
312+
const addNextSuggestion = () => {
313+
useSetupStore
314+
.getState()
315+
.addDiscoveredTaskIfMissing(
316+
buildSimulatedSuggestion(directory, this.simulatedSuggestionsCount),
317+
);
318+
this.simulatedSuggestionsCount += 1;
319+
};
320+
321+
addNextSuggestion();
322+
this.simulatedSuggestionsTimer = window.setInterval(
323+
addNextSuggestion,
324+
SIMULATED_SUGGESTION_INTERVAL_MS,
325+
);
326+
}
327+
259328
startDiscovery(directory: string): void {
260329
if (!directory) return;
261330
if (this.anyDiscoveryEverLaunched) return;

apps/code/src/renderer/features/setup/stores/setupStore.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ interface SetupStoreActions {
6565
completeEnrichment: (repoPath: string) => void;
6666
failEnrichment: (repoPath: string) => void;
6767
removeDiscoveredTask: (taskId: string, repoPath: string | null) => void;
68+
addDiscoveredTaskIfMissing: (task: DiscoveredTask) => void;
6869
addEnricherSuggestionIfMissing: (task: DiscoveredTask) => void;
6970
pushDiscoveryActivity: (repoPath: string, entry: ActivityEntry) => void;
7071
resetSetup: () => void;
@@ -263,6 +264,21 @@ export const useSetupStore = create<SetupStore>()(
263264
}));
264265
},
265266

267+
addDiscoveredTaskIfMissing: (task) => {
268+
set((state) => {
269+
if (
270+
state.discoveredTasks.some(
271+
(t) => t.id === task.id && t.repoPath === task.repoPath,
272+
)
273+
) {
274+
return state;
275+
}
276+
return {
277+
discoveredTasks: [...state.discoveredTasks, task],
278+
};
279+
});
280+
},
281+
266282
// Adds an enricher-source suggestion if there isn't already one with
267283
// the same id+repoPath. Idempotent — safe to call repeatedly on every
268284
// detection run. Dismissed suggestions stay dismissed until `resetSetup`.

apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@ import { motion } from "framer-motion";
99

1010
export interface SuggestedTaskCardProps {
1111
task: DiscoveredTask;
12-
index: number;
1312
onSelect: (task: DiscoveredTask) => void;
1413
onDismiss: (task: DiscoveredTask) => void;
1514
}
1615

1716
export function SuggestedTaskCard({
1817
task,
19-
index,
2018
onSelect,
2119
onDismiss,
2220
}: SuggestedTaskCardProps) {
@@ -26,19 +24,15 @@ export function SuggestedTaskCard({
2624
return (
2725
<motion.div
2826
layout
29-
initial={{ opacity: 0, y: 6 }}
30-
animate={{ opacity: 1, y: 0 }}
31-
exit={{
32-
opacity: 0,
33-
y: -4,
34-
transition: { duration: 0.12, delay: 0 },
35-
}}
27+
initial={{ opacity: 0, scale: 0.97 }}
28+
animate={{ opacity: 1, scale: 1 }}
29+
exit={{ opacity: 0, scale: 0.95 }}
3630
transition={{
37-
duration: 0.18,
38-
delay: index * 0.04,
39-
layout: { type: "spring", damping: 25, stiffness: 300 },
31+
opacity: { duration: 0.15, ease: "easeOut" },
32+
scale: { duration: 0.15, ease: "easeOut" },
33+
layout: { duration: 0.22, ease: [0.22, 1, 0.36, 1] },
4034
}}
41-
className="group relative"
35+
className="group relative origin-center"
4236
>
4337
<button
4438
onClick={() => onSelect(task)}

apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,6 @@ const BOTTOM_PADDING = 56;
3737
const LOG_LINE_HEIGHT = 24;
3838
const LOG_FEED_PADDING = 16;
3939

40-
const pageVariants = {
41-
enter: (dir: number) => ({ x: dir * 32, opacity: 0 }),
42-
center: { x: 0, opacity: 1 },
43-
exit: (dir: number) => ({ x: -dir * 32, opacity: 0 }),
44-
};
45-
4640
const fadeMotion = {
4741
initial: { opacity: 0 },
4842
animate: { opacity: 1 },
@@ -127,7 +121,7 @@ export function SuggestedTasksPanel() {
127121

128122
const hasTasks = discoveredTasks.length > 0;
129123
const showEnricherFeed = !hasTasks && enricherStatus === "running";
130-
const showDiscoveryFeed = discoveryStatus === "running";
124+
const showDiscoveryFeed = !hasTasks && discoveryStatus === "running";
131125

132126
if (!hasTasks && !showEnricherFeed && !showDiscoveryFeed) return null;
133127

@@ -227,31 +221,42 @@ export function SuggestedTasksPanel() {
227221
</Flex>
228222
)}
229223
{hasTasks && (
230-
<div className="relative overflow-hidden">
231-
<AnimatePresence
232-
mode="popLayout"
233-
initial={false}
234-
custom={pageDirection}
235-
>
224+
<div className="relative">
225+
<AnimatePresence mode="wait" initial={false} custom={pageDirection}>
236226
<motion.div
237227
key={effectivePageStart}
238228
custom={pageDirection}
239-
variants={pageVariants}
240229
initial="enter"
241230
animate="center"
242231
exit="exit"
243-
transition={{ duration: 0.18, ease: "easeOut" }}
232+
variants={{
233+
enter: (dir: number) => ({ x: dir * 32, opacity: 0 }),
234+
center: {
235+
x: 0,
236+
opacity: 1,
237+
transition: {
238+
x: { duration: 0.22, ease: [0.22, 1, 0.36, 1] },
239+
opacity: { duration: 0.18, ease: "easeOut" },
240+
},
241+
},
242+
exit: (dir: number) => ({
243+
x: -dir * 32,
244+
opacity: 0,
245+
transition: { duration: 0.13, ease: "easeIn" },
246+
}),
247+
}}
244248
className="flex flex-col gap-2"
245249
>
246-
{visibleTasks.map((task, index) => (
247-
<SuggestedTaskCard
248-
key={task.id}
249-
task={task}
250-
index={index}
251-
onSelect={handleSelectTask}
252-
onDismiss={handleDismiss}
253-
/>
254-
))}
250+
<AnimatePresence mode="sync" initial={false}>
251+
{visibleTasks.map((task) => (
252+
<SuggestedTaskCard
253+
key={task.id}
254+
task={task}
255+
onSelect={handleSelectTask}
256+
onDismiss={handleDismiss}
257+
/>
258+
))}
259+
</AnimatePresence>
255260
</motion.div>
256261
</AnimatePresence>
257262
</div>

apps/code/src/renderer/features/task-detail/components/TaskInput.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -621,20 +621,20 @@ export function TaskInput({
621621
className="relative h-full w-full"
622622
>
623623
<DropZoneOverlay isVisible={isDraggingFile} />
624-
<Flex
625-
align="center"
626-
justify="center"
627-
height="100%"
628-
className="relative px-4 pt-[10vh]"
629-
>
624+
<Flex height="100%" className="relative px-4">
630625
<DotPatternBackground className="h-[100.333%]" />
631626
<div
632627
style={{
633-
zIndex: 1,
628+
top: "50%",
629+
transform: "translate(-50%, -50%)",
634630
}}
635-
className="relative flex w-full max-w-[600px] flex-col gap-2"
631+
className="absolute left-1/2 z-[1] flex w-[calc(100%-2rem)] max-w-[600px] flex-col gap-2"
636632
>
637-
<Flex gap="2" align="center" className="min-w-0">
633+
<Flex
634+
gap="2"
635+
align="center"
636+
className="absolute bottom-full left-0 mb-2 min-w-0"
637+
>
638638
<WorkspaceModeSelect
639639
value={workspaceMode}
640640
onChange={setWorkspaceMode}
@@ -829,8 +829,10 @@ export function TaskInput({
829829
<CloudGithubMissingNotice />
830830
</div>
831831
)}
832-
<SuggestedTasksPanel />
833832
</Flex>
833+
<div className="absolute top-full right-0 left-0 z-10">
834+
<SuggestedTasksPanel />
835+
</div>
834836
</div>
835837
</Flex>
836838

apps/code/src/vite-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface ImportMetaEnv {
1111
readonly VITE_POSTHOG_API_KEY?: string;
1212
readonly VITE_POSTHOG_API_HOST?: string;
1313
readonly VITE_POSTHOG_UI_HOST?: string;
14+
readonly VITE_SIMULATE_SUGGESTIONS?: string;
1415
}
1516

1617
interface ImportMeta {

0 commit comments

Comments
 (0)