)}
@@ -298,13 +302,18 @@ export default function SandboxPage() {
return (
-
+
{session.draftPrompt && (
)}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.client.test.tsx
new file mode 100644
index 000000000..c27d345bd
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.client.test.tsx
@@ -0,0 +1,74 @@
+import type { ReactNode } from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+
+vi.mock('@/components/ai-elements', () => ({
+ Message: ({ children }: { children: ReactNode }) =>
{children}
,
+ MessageContent: ({ children }: { children: ReactNode }) => (
+
{children}
+ ),
+}));
+
+vi.mock('@/components/system', () => ({
+ ArrowRight: () => null,
+ BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ Button: ({
+ children,
+ onClick,
+ ...props
+ }: React.ButtonHTMLAttributes
) => (
+
+ ),
+ Lightbulb: () => null,
+ X: () => null,
+}));
+
+import {
+ getTipDisplayDurationMs,
+ PRODUCT_TIPS,
+ ProductTips,
+} from './ProductTips';
+
+describe('ProductTips', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.spyOn(Math, 'random').mockReturnValue(0.99);
+ window.localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it('cycles through the shuffled tips using reading-time durations', () => {
+ render();
+
+ act(() => {});
+ expect(screen.getByText(PRODUCT_TIPS[0].title)).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(getTipDisplayDurationMs(PRODUCT_TIPS[0]));
+ });
+
+ expect(screen.getByText(PRODUCT_TIPS[1].title)).toBeInTheDocument();
+ });
+
+ it('persists dismissal and stays hidden on later mounts', () => {
+ const { unmount } = render();
+
+ act(() => {});
+ fireEvent.click(screen.getByRole('button', { name: 'Hide product tips' }));
+
+ expect(screen.queryByText(PRODUCT_TIPS[0].title)).not.toBeInTheDocument();
+
+ unmount();
+ render();
+ act(() => {});
+
+ expect(
+ screen.queryByRole('button', { name: 'Hide product tips' }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.tsx
new file mode 100644
index 000000000..514307c33
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.tsx
@@ -0,0 +1,209 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+
+import {
+ ArrowRight,
+ BasicTooltip,
+ Button,
+ Lightbulb,
+ X,
+} from '@/components/system';
+import { Message, MessageContent } from '@/components/ai-elements';
+
+const PRODUCT_TIPS_DISMISSED_STORAGE_KEY = 'roomote:product-tips-dismissed:v1';
+
+const MIN_TIP_DURATION_MS = 8_000;
+const MAX_TIP_DURATION_MS = 15_000;
+const READING_CHARS_PER_SECOND = 10;
+
+export const PRODUCT_TIPS = [
+ {
+ title: 'Debug production backward',
+ description:
+ 'Ask Roomote to inspect a Sentry issue, deployment logs, or Grafana alert, trace it into the codebase, and prepare the smallest verified fix.',
+ },
+ {
+ title: 'Turn failed CI into a fix',
+ description:
+ 'CI Failure Triage can detect a persistent failure on the default branch, reproduce it in the configured environment, and open a fix PR.',
+ },
+ {
+ title: 'Investigate surprising metrics',
+ description:
+ 'Connect PostHog and ask why a metric moved. Roomote can inspect events, experiments, and feature flags, then trace likely causes into the code.',
+ },
+ {
+ title: 'Debug with real database state',
+ description:
+ 'Use read-only Supabase or Neon access to investigate data-dependent bugs, compare the live schema with application assumptions, and plan a safe fix.',
+ },
+ {
+ title: 'Build directly from the spec',
+ description:
+ 'Point Roomote at a Notion spec or Linear issue and ask it to trace affected code, identify missing decisions, implement the change, and link the PR back.',
+ },
+ {
+ title: 'Turn support into engineering work',
+ description:
+ 'Auto-respond in a support or bug channel so new reports become repository-grounded investigations—even when nobody explicitly mentions Roomote.',
+ },
+ {
+ title: 'Triage issues as they arrive',
+ description:
+ 'Roomote can investigate each newly opened issue and post a concrete implementation plan with the relevant code paths before anyone picks it up.',
+ },
+ {
+ title: 'Keep old PRs mergeable',
+ description:
+ 'Label selected pull requests for automatic conflict resolution. Roomote periodically rebases the work, resolves safe conflicts, and updates the branch.',
+ },
+ {
+ title: 'Audit what just shipped',
+ description:
+ 'Run security and code-quality auditors over recently merged PRs to surface high-confidence risks and maintainability problems as follow-up work.',
+ },
+ {
+ title: 'Schedule repository-aware work',
+ description:
+ 'Create recurring tasks such as release-readiness checks, dependency audits, or weekly product reports that can inspect your code and connected tools.',
+ },
+] as const;
+
+type ProductTip = (typeof PRODUCT_TIPS)[number];
+
+export function getTipDisplayDurationMs(tip: ProductTip): number {
+ const readingTime =
+ Math.ceil(
+ `${tip.title} ${tip.description}`.length / READING_CHARS_PER_SECOND,
+ ) * 1_000;
+
+ return Math.min(
+ MAX_TIP_DURATION_MS,
+ Math.max(MIN_TIP_DURATION_MS, readingTime),
+ );
+}
+
+function shuffleTips(): ProductTip[] {
+ const tips = [...PRODUCT_TIPS];
+
+ for (let index = tips.length - 1; index > 0; index -= 1) {
+ const swapIndex = Math.floor(Math.random() * (index + 1));
+ [tips[index], tips[swapIndex]] = [tips[swapIndex]!, tips[index]!];
+ }
+
+ return tips;
+}
+
+export function ProductTips() {
+ const initialized = useRef(false);
+ const [tips, setTips] = useState([]);
+ const [tipIndex, setTipIndex] = useState(0);
+
+ useEffect(() => {
+ if (initialized.current) {
+ return;
+ }
+
+ initialized.current = true;
+
+ if (
+ window.localStorage.getItem(PRODUCT_TIPS_DISMISSED_STORAGE_KEY) === '1'
+ ) {
+ return;
+ }
+
+ setTips(shuffleTips());
+ }, []);
+
+ useEffect(() => {
+ const tip = tips[tipIndex];
+
+ if (!tip || tips.length < 2) {
+ return;
+ }
+
+ const durationMs = getTipDisplayDurationMs(tip);
+
+ const timer = window.setTimeout(() => {
+ setTipIndex((currentIndex) => (currentIndex + 1) % tips.length);
+ }, durationMs);
+
+ return () => {
+ window.clearTimeout(timer);
+ };
+ }, [tipIndex, tips]);
+
+ const tip = tips[tipIndex];
+
+ if (!tip) {
+ return null;
+ }
+
+ const dismiss = () => {
+ window.localStorage.setItem(PRODUCT_TIPS_DISMISSED_STORAGE_KEY, '1');
+ setTips([]);
+ };
+
+ const showNextTip = () => {
+ setTipIndex((currentIndex) => (currentIndex + 1) % tips.length);
+ };
+
+ const durationMs = getTipDisplayDurationMs(tip);
+ const totalSeconds = Math.ceil(durationMs / 1_000);
+
+ return (
+
+
+
+
+
+
{tip.title}
+
+ {tip.description}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx
index a693ec65d..8b930e954 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx
@@ -47,6 +47,7 @@ vi.mock('@/components/system', () => ({
ThumbsDown: () => null,
SquareDashedMousePointer: () => null,
MessageSquareIcon: () => null,
+ MessageSquareWarning: () => null,
RotateCcw: () => null,
}));
@@ -182,7 +183,7 @@ describe('StartupSequence', () => {
).toHaveAttribute('href', 'https://example.com/shot.png');
});
- it('uses a flex-bounded scroll container when startup content exceeds the available height', () => {
+ it('renders startup content inline without its own scroll surface', () => {
const { container } = render(
{
);
const outerContainer = container.firstChild as HTMLElement | null;
- const innerContent = outerContainer?.firstChild as HTMLElement | null;
- expect(outerContainer).toHaveClass(
- 'flex-1',
- 'min-h-0',
- 'overflow-y-auto',
- 'p-4',
- );
- expect(innerContent).toHaveClass('mx-auto', 'w-full', 'max-w-4xl', 'px-2');
+ expect(outerContainer).toHaveClass('flex', 'flex-col', 'gap-2');
+ expect(outerContainer).not.toHaveClass('overflow-y-auto', 'p-4');
});
});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx
index ac130aa64..1797ba054 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx
@@ -22,7 +22,7 @@ import { getTaskRunErrorDisplayMessage } from '@/lib/task-run-errors';
import { Message, MessageContent, Shimmer } from '@/components/ai-elements';
import { SandboxLogsTerminal } from '@/components/sandbox';
-import { MessageSquareWarning, RotateCcw } from 'lucide-react';
+import { MessageSquareWarning, RotateCcw } from '@/components/system';
export interface StartupStep {
status: RunStatus;
@@ -324,32 +324,30 @@ export const StartupSequence = ({
: -1;
return (
-
-
- {steps.map((step, index) => (
-
-
+ {steps.map((step, index) => (
+
+
+ {index === logInsertIndex && (
+
- {index === logInsertIndex && (
-
- )}
-
- ))}
-
-
+ )}
+
+ ))}
+
);
};
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts b/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts
index 42f9e2828..77c7bec10 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts
@@ -1 +1,2 @@
export * from './Startup';
+export * from './ProductTips';
diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts
index 49fbf1edc..9ee447ac7 100644
--- a/apps/web/src/components/system/primitives/icons.ts
+++ b/apps/web/src/components/system/primitives/icons.ts
@@ -124,6 +124,7 @@ export {
MessageSquareIcon,
MessageSquarePlus,
MessageSquareText,
+ MessageSquareWarning,
MessagesSquare,
Mic,
MicOff,