diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx index 913e28711..5b0505f75 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx @@ -32,10 +32,6 @@ vi.mock('./prompt-input', () => ({ PromptInput: () =>
, })); -vi.mock('./startup', () => ({ - Startup: () =>
, -})); - import { TaskInputStack } from './TaskInputStack'; const baseSession: TaskSession = { @@ -70,7 +66,7 @@ describe('TaskInputStack', () => { }); }); - it('shows the startup surface instead of the prompt input while booting', () => { + it('leaves the input area empty while startup renders in the conversation', () => { render( { />, ); - expect(screen.getByTestId('startup')).toBeInTheDocument(); expect(screen.queryByTestId('prompt-input')).not.toBeInTheDocument(); }); @@ -100,6 +95,5 @@ describe('TaskInputStack', () => { ); expect(screen.getByTestId('prompt-input')).toBeInTheDocument(); - expect(screen.queryByTestId('startup')).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx index b77e6faae..d8f540960 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx @@ -32,6 +32,7 @@ import { ConnectionStatusBanner } from './ErrorFallback'; import { PendingUserInputRequestStateProvider } from './PendingUserInputRequestPanel'; import { TaskInputStack } from './TaskInputStack'; import { OnboardingCompletionMessage } from './OnboardingCompletionMessage'; +import { ProductTips, Startup } from './startup'; interface LiveContentProps { session: TaskSession; @@ -89,6 +90,8 @@ function LiveContentInner({ }, [onTaskPhaseChange, taskPhase]); const asleep = isTaskRunAsleep(session.taskRun); + const bootingTaskRun = + session.sessionState === 'booting' ? session.taskRun : null; const [messagesInitialScrollBehavior, setMessagesInitialScrollBehavior] = useState<'smooth' | 'instant'>('smooth'); @@ -251,11 +254,33 @@ function LiveContentInner({ scrollRef={messagesRef} initialScrollBehavior={messagesInitialScrollBehavior} footer={ - session.onboardingEnvironment ? ( - - ) : undefined + <> + {session.onboardingEnvironment && ( + + )} + {bootingTaskRun && ( + <> + + + + )} + } />
@@ -265,7 +290,6 @@ function LiveContentInner({ promptInputRef={promptInputRef} onFileSearchOpen={handleFileSearchOpen} onCommandSearchOpen={handleCommandSearchOpen} - onBootStatusChange={onBootStatusChange} scrollToBottom={scrollToBottom} /> diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx index 4de01f857..fb9f1e11a 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx @@ -10,7 +10,6 @@ import { import { PendingEnvVarRequestPanel } from './PendingEnvVarRequestPanel'; import { PromptInput, type PromptInputHandle } from './prompt-input'; import { QueuedMessages } from './QueuedMessages'; -import { Startup } from './startup'; import { ActiveSubtasksList } from './ActiveSubtasksList'; import { TodoList } from './TodoList'; @@ -19,22 +18,19 @@ export function TaskInputStack({ promptInputRef, onFileSearchOpen, onCommandSearchOpen, - onBootStatusChange, scrollToBottom, }: { session: TaskSession; promptInputRef: { current: PromptInputHandle | null }; onFileSearchOpen: (insertPosition?: number) => void; onCommandSearchOpen: (insertPosition?: number) => void; - onBootStatusChange?: () => void; scrollToBottom: () => void; }) { const { shouldHidePromptInput } = usePendingUserInputRequestState(); const [visibleEnvVarRequestKey, setVisibleEnvVarRequestKey] = useState< string | null >(null); - const bootingTaskRun = - session.sessionState === 'booting' ? session.taskRun : null; + const isBooting = session.sessionState === 'booting'; useEffect(() => { setVisibleEnvVarRequestKey(null); @@ -53,24 +49,7 @@ export function TaskInputStack({ onVisibleRequestKeyChange={setVisibleEnvVarRequestKey} /> - {bootingTaskRun ? ( -
- -
- ) : ( + {!isBooting && (
({ vi.mock('./startup', () => ({ Startup: () =>
, + ProductTips: () =>
, SnapshotResumeFailureFooter: () => (
), @@ -208,6 +209,7 @@ describe('SandboxPage', () => { renderPage(); expect(screen.getByTestId('startup')).toBeInTheDocument(); + expect(screen.getByTestId('product-tips')).toBeInTheDocument(); expect(screen.queryByTestId('sandbox-provider')).not.toBeInTheDocument(); }); @@ -323,6 +325,7 @@ describe('SandboxPage', () => { renderPage(); expect(screen.getByTestId('startup')).toBeInTheDocument(); + expect(screen.queryByTestId('product-tips')).not.toBeInTheDocument(); expect(screen.queryByTestId('historical-content')).not.toBeInTheDocument(); expect( screen.queryByTestId('snapshot-resume-failure-footer'), diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index bc7d33fd7..eec5a064f 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -34,7 +34,7 @@ import { useTaskMessageEnvelopes, } from './hooks'; -import { SnapshotResumeFailureFooter, Startup } from './startup'; +import { ProductTips, SnapshotResumeFailureFooter, Startup } from './startup'; import { DraftPromptBanner } from './DraftPromptBanner'; import { Header } from './Header'; import { HistoricalContent } from './HistoricalContent'; @@ -280,13 +280,17 @@ export default function SandboxPage() { return (
- +
+
+ +
+
{session.draftPrompt && ( )} @@ -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,