diff --git a/backend/app/model/session.py b/backend/app/model/session.py index dc553d9..5b93141 100644 --- a/backend/app/model/session.py +++ b/backend/app/model/session.py @@ -39,6 +39,11 @@ class ProposalStatus(enum.StrEnum): FAILED = "failed" +class DeviceType(enum.StrEnum): + DESKTOP = "desktop" + MOBILE = "mobile" + + def _utcnow() -> datetime: return datetime.now(UTC) @@ -84,6 +89,7 @@ class Iteration(Base): default=IterationStatus.PENDING, ) before_screenshot_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + device_type: Mapped[DeviceType | None] = mapped_column(String(20), nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) k8s_analyzer_job_name: Mapped[str | None] = mapped_column(String(200), nullable=True) version: Mapped[int] = mapped_column(Integer, default=1) diff --git a/backend/app/router/sessions.py b/backend/app/router/sessions.py index c2e5718..a4d06a9 100644 --- a/backend/app/router/sessions.py +++ b/backend/app/router/sessions.py @@ -83,6 +83,7 @@ def _to_iteration_response(iteration: Iteration, session_id: UUID) -> IterationR if iteration.before_screenshot_key else None ), + device_type=iteration.device_type, error_message=iteration.error_message, proposals=proposals, created_at=iteration.created_at, diff --git a/backend/app/schema/session_schema.py b/backend/app/schema/session_schema.py index dc000f4..0d8f09d 100644 --- a/backend/app/schema/session_schema.py +++ b/backend/app/schema/session_schema.py @@ -50,6 +50,7 @@ class IterationResponse(BaseModel): selected_proposal_index: int | None = None status: str before_screenshot_url: str | None = None + device_type: str | None = None error_message: str | None = None proposals: list[ProposalResponse] = [] created_at: datetime diff --git a/backend/app/usecase/session_usecase.py b/backend/app/usecase/session_usecase.py index 04f31a4..0e4862b 100644 --- a/backend/app/usecase/session_usecase.py +++ b/backend/app/usecase/session_usecase.py @@ -333,6 +333,7 @@ async def _run_session_analysis( UUID(iteration_id), IterationStatus.ANALYZED, before_screenshot_key=result.get("before_screenshot_key"), + device_type=device_type, ) logger.info( "Session %s iter %d analysis done: %d proposals (device: %s)", diff --git a/backend/migration/versions/d4e5f6g7h8i9_add_device_type_to_iterations.py b/backend/migration/versions/d4e5f6g7h8i9_add_device_type_to_iterations.py new file mode 100644 index 0000000..e116915 --- /dev/null +++ b/backend/migration/versions/d4e5f6g7h8i9_add_device_type_to_iterations.py @@ -0,0 +1,26 @@ +"""add device_type to iterations + +Revision ID: d4e5f6g7h8i9 +Revises: c3d4e5f6g7h8 +Create Date: 2026-03-07 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "d4e5f6g7h8i9" +down_revision: Union[str, None] = "c3d4e5f6g7h8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("iterations", sa.Column("device_type", sa.String(length=20), nullable=True)) + + +def downgrade() -> None: + op.drop_column("iterations", "device_type") diff --git a/docker/worker-analyze.py b/docker/worker-analyze.py index 9342271..e4ae5ad 100644 --- a/docker/worker-analyze.py +++ b/docker/worker-analyze.py @@ -20,11 +20,11 @@ PLAYWRIGHT_MCP_SERVERS = { "playwright": { "command": "npx", - "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium"], + "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--viewport-size", "1280x800"], }, "playwright_mobile": { "command": "npx", - "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium", "--device", "iPhone 15"], + "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--device", "iPhone 15"], }, } @@ -68,8 +68,6 @@ def _emit_tool_detail(phase: str, tool_name: str, raw_input: str) -> None: elif tool_name == "Bash": cmd = params.get("command", "?") emit_log(phase, f"Running: {cmd[:100]}") - elif tool_name in ("Glob", "Grep"): - emit_log(phase, f"Searching: {params.get('pattern', '?')}") def get_s3_client(): @@ -153,7 +151,10 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): - emit_log(phase, block.text[:200], detail=block.text) + text = block.text.strip() + if text.startswith("Browser") or text.startswith("Searching"): + continue + emit_log(phase, text[:200], detail=block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail(phase, block.name, json.dumps(block.input)) @@ -369,7 +370,10 @@ async def generate_proposals( if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): - emit_log("analyzing", block.text[:200], detail=block.text) + text = block.text.strip() + if text.startswith("Browser") or text.startswith("Searching"): + continue + emit_log("analyzing", text[:200], detail=block.text) collected_text.append(block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail("analyzing", block.name, json.dumps(block.input)) diff --git a/docker/worker-implement.py b/docker/worker-implement.py index 7fb8b0b..d5633e0 100644 --- a/docker/worker-implement.py +++ b/docker/worker-implement.py @@ -20,11 +20,11 @@ PLAYWRIGHT_MCP_SERVERS = { "playwright": { "command": "npx", - "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium"], + "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--viewport-size", "1280x800"], }, "playwright_mobile": { "command": "npx", - "args": ["@playwright/mcp@latest", "--headless", "--browser", "chromium", "--device", "iPhone 15"], + "args": ["@playwright/mcp@0.0.68", "--headless", "--browser", "chromium", "--device", "iPhone 15"], }, } @@ -68,8 +68,6 @@ def _emit_tool_detail(phase: str, tool_name: str, raw_input: str) -> None: elif tool_name == "Bash": cmd = params.get("command", "?") emit_log(phase, f"Running: {cmd[:100]}") - elif tool_name in ("Glob", "Grep"): - emit_log(phase, f"Searching: {params.get('pattern', '?')}") def get_s3_client(): @@ -174,7 +172,10 @@ async def implement_changes(repo_dir: str, proposal_plan: str) -> None: if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): - emit_log("implementing", block.text[:200], detail=block.text) + text = block.text.strip() + if text.startswith("Browser") or text.startswith("Searching"): + continue + emit_log("implementing", text[:200], detail=block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail("implementing", block.name, json.dumps(block.input)) @@ -207,7 +208,10 @@ async def _process_messages(client: ClaudeSDKClient, phase: str) -> None: if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): - emit_log(phase, block.text[:200], detail=block.text) + text = block.text.strip() + if text.startswith("Browser") or text.startswith("Searching"): + continue + emit_log(phase, text[:200], detail=block.text) elif isinstance(block, ToolUseBlock): _emit_tool_detail(phase, block.name, json.dumps(block.input)) diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile index 210e270..9882443 100644 --- a/docker/worker.Dockerfile +++ b/docker/worker.Dockerfile @@ -47,8 +47,9 @@ RUN apt-get update && apt-get install -y \ WORKDIR /workspace -# Install Playwright and Chromium (without --with-deps since deps are installed above) -RUN npm install -g playwright@1.49.0 @playwright/mcp@latest && \ +# Install Playwright MCP with pinned version and its bundled Chromium +RUN npm install -g @playwright/mcp@0.0.68 && \ + cd /usr/lib/node_modules/@playwright/mcp && \ npx playwright install chromium # Install GitHub CLI diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c1871dc..c94ffdc 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,15 +1,17 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, type ReactNode } from 'react' import { Outlet } from 'react-router-dom' import { listSessions, type Session } from '../services/api' import Sidebar from './Sidebar' type LayoutContext = { refreshSessions: () => void + setHeaderExtra: (content: ReactNode | null) => void } export default function Layout() { const [sidebarOpen, setSidebarOpen] = useState(true) const [sessions, setSessions] = useState([]) + const [headerExtra, setHeaderExtra] = useState(null) const refreshSessions = useCallback(() => { listSessions() @@ -38,6 +40,12 @@ export default function Layout() { display: 'flex', alignItems: 'center', gap: '12px', + borderBottom: '1px solid #374151', + marginBottom: '24px', + position: 'sticky', + top: 0, + zIndex: 10, + backgroundColor: '#242424', }} > {!sidebarOpen && ( @@ -55,11 +63,23 @@ export default function Layout() { )} - + UI Recommender + {headerExtra && ( +
+ {headerExtra} +
+ )} - + ) diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx index 7d292dc..6fba2c4 100644 --- a/frontend/src/components/LogPanel.tsx +++ b/frontend/src/components/LogPanel.tsx @@ -222,8 +222,8 @@ export default function LogPanel({ logState, defaultCollapsed }: LogPanelProps) {/* Expanded content */} {expanded && (
- {/* Tab bar (show only if multiple jobs) */} - {jobKeys.length > 1 && ( + {/* Tab bar */} + {jobKeys.length > 0 && (
void + readOnly?: boolean } -export default function ProposalCard({ proposal, selected, onToggle }: ProposalCardProps) { +export default function ProposalCard({ + proposal, + selected, + onToggle, + readOnly, +}: ProposalCardProps) { if (proposal.status !== 'completed' || !proposal.after_screenshot_url) { return null } + const borderColor = readOnly && selected ? '#34d399' : selected ? '#3b82f6' : '#e5e7eb' + const borderWidth = selected ? '3px' : '1px' + const bgColor = readOnly && selected ? '#ecfdf5' : selected ? '#eff6ff' : '#fff' + const shadow = + readOnly && selected + ? '0 0 0 2px rgba(52,211,153,0.3)' + : selected + ? '0 0 0 2px rgba(59,130,246,0.3)' + : 'none' + return (
onToggle(proposal.proposal_index)} + onClick={readOnly ? undefined : () => onToggle(proposal.proposal_index)} >
-

+

#{proposal.proposal_index + 1}: {proposal.title}

diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a581893..f10369c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -56,7 +56,6 @@ export default function Sidebar({ sessions, isOpen, onToggle }: SidebarProps) { display: 'flex', justifyContent: 'space-between', alignItems: 'center', - borderBottom: '1px solid #374151', }} > void + setHeaderExtra: (content: ReactNode | null) => void } export function useLayoutContext() { diff --git a/frontend/src/index.css b/frontend/src/index.css index 13aaf0a..2f6a19e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -57,6 +57,12 @@ button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } +@media (max-width: 768px) { + .header-repo-info { + display: none !important; + } +} + @media (prefers-color-scheme: light) { :root { color: #213547; diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index eb0dc20..60577e1 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect, useRef, Fragment } from 'react' import { useParams } from 'react-router-dom' import { useSessionPolling } from '../hooks/useSessionPolling' import { useLogStream } from '../hooks/useLogStream' @@ -8,10 +8,242 @@ import { useLayoutContext } from '../hooks/useLayoutContext' import StatusBadge from '../components/StatusBadge' import ProposalCard from '../components/ProposalCard' import LogPanel from '../components/LogPanel' +import type { LogStreamState } from '../hooks/useLogStream' + +function extractRepoName(repoUrl: string): string { + try { + const parts = repoUrl.replace(/\.git$/, '').split('/') + return parts.slice(-2).join('/') + } catch { + return repoUrl + } +} + +/* ------------------------------------------------------------------ */ +/* IterationBlock — renders a single iteration in the chat stack */ +/* ------------------------------------------------------------------ */ + +type IterationBlockProps = { + iteration: Iteration + isLatest: boolean + // latest-only interactive props + selectedIndex: number | null + onToggleProposal: (index: number) => void + onCreatePR: () => void + prLoading: boolean + prError: string | null + // log stream + logStreamState: LogStreamState + isInProgress: boolean +} + +function IterationBlock({ + iteration, + isLatest, + selectedIndex, + onToggleProposal, + onCreatePR, + prLoading, + prError, + logStreamState, + isInProgress, +}: IterationBlockProps) { + const isMobile = iteration.device_type === 'mobile' + const cardMinWidth = isMobile ? '240px' : '400px' + const completedProposals = iteration.proposals.filter( + (p) => p.status === 'completed' && p.after_screenshot_url, + ) + + // For past iterations, highlight the selected proposal; for latest, use interactive selection + const effectiveSelectedIndex = isLatest ? selectedIndex : iteration.selected_proposal_index + + const selectedProposal: Proposal | null = + effectiveSelectedIndex !== null + ? (completedProposals.find((p) => p.proposal_index === effectiveSelectedIndex) ?? null) + : null + + return ( +
+ {/* Instruction bubble — chat-style, right-aligned */} +
+
+ {iteration.instruction} +
+
+ + {/* Error message */} + {iteration.error_message && ( +
+ {iteration.error_message} +
+ )} + + {/* Log panel — live when latest & in-progress, collapsed otherwise */} + {isLatest && (isInProgress || logStreamState.jobs.size > 0) && ( + + )} + + {/* Proposals grid */} + {iteration.status === 'completed' && completedProposals.length > 0 && ( +
+

+ {isLatest + ? `Select a design (${completedProposals.length} proposals)` + : `Proposals (${completedProposals.length})`} +

+
+ {iteration.before_screenshot_url && ( +
+ Before +
+

+ Before +

+
+
+ )} + {completedProposals.map((proposal) => ( + + ))} +
+ + {/* Create PR button — latest only */} + {isLatest && ( + <> + {/* Create PR button */} +
+ {(() => { + const sp = selectedProposal + if (sp?.pr_url) { + return ( + + View PR + + ) + } + if (sp?.pr_status === 'creating') { + return ( +
+ Creating PR... +
+ ) + } + const canCreate = !!sp && !sp.pr_status && !sp.pr_url + const isFailed = sp?.pr_status === 'failed' + return ( + <> + {isFailed && ( +

+ PR creation failed +

+ )} + + + ) + })()} + + {prError && ( +

{prError}

+ )} +
+ + )} +
+ )} +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* SessionDetail — main component */ +/* ------------------------------------------------------------------ */ export default function SessionDetail() { const { sessionId } = useParams<{ sessionId: string }>() - const { refreshSessions } = useLayoutContext() + const { refreshSessions, setHeaderExtra } = useLayoutContext() const { session, error, isLoading, refetch } = useSessionPolling(sessionId ?? null) // Compute isInProgress early so useLogStream can be called unconditionally @@ -29,18 +261,25 @@ export default function SessionDetail() { const [prLoading, setPrLoading] = useState(false) const [prError, setPrError] = useState(null) const prPollRef = useRef | null>(null) - const [showContinueForm, setShowContinueForm] = useState(false) const [continueInstruction, setContinueInstruction] = useState('') const [continueLoading, setContinueLoading] = useState(false) const [continueError, setContinueError] = useState(null) + const bottomRef = useRef(null) // Reset selection when session changes useEffect(() => { setSelectedIndex(null) - setShowContinueForm(false) setContinueInstruction('') }, [sessionId]) + // Auto-scroll when new iterations are added + const iterationCount = session?.iterations.length ?? 0 + useEffect(() => { + if (iterationCount > 0) { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [iterationCount]) + const latestIteration: Iteration | null = session && session.iterations.length > 0 ? session.iterations[session.iterations.length - 1] @@ -72,7 +311,6 @@ export default function SessionDetail() { await iterate(sessionId, selectedIndex, continueInstruction) refreshSessions() refetch() - setShowContinueForm(false) setContinueInstruction('') setSelectedIndex(null) } catch (e) { @@ -119,6 +357,50 @@ export default function SessionDetail() { } }, [session, sessionId, latestIteration, refetch]) + // Push repo/branch/badge into the Layout header + const iterationStatus = latestIteration?.status ?? 'pending' + useEffect(() => { + if (session) { + setHeaderExtra( + <> +
+ + + + + {extractRepoName(session.repo_url)} + + + + + + {session.base_branch} + +
+
+ +
+ , + ) + } + return () => setHeaderExtra(null) + }, [session, iterationStatus, setHeaderExtra]) + if (!sessionId) return

Invalid session ID

if (isLoading && !session) { @@ -139,318 +421,147 @@ export default function SessionDetail() { if (!session) return null - const iterationStatus = latestIteration?.status ?? 'pending' const isInProgress = ['pending', 'analyzing', 'implementing'].includes(iterationStatus) - const completedProposals = + + const completedProposalsForInput = latestIteration?.proposals.filter((p) => p.status === 'completed' && p.after_screenshot_url) ?? [] - - const selectedProposal: Proposal | null = + const selectedProposal = selectedIndex !== null - ? (completedProposals.find((p) => p.proposal_index === selectedIndex) ?? null) + ? (completedProposalsForInput.find((p) => p.proposal_index === selectedIndex) ?? null) : null + const showContinueInput = + latestIteration?.status === 'completed' && completedProposalsForInput.length > 0 return ( -
- {/* Session header */} -
-
-

Session Detail

- -
-
-
Repository: {session.repo_url}
-
Branch: {session.base_branch}
-
-
- - {/* Iteration timeline */} - {session.iterations.length > 1 && ( -
-

- Iterations ({session.iterations.length}) -

-
- {session.iterations.map((iter, idx) => ( -
+ {/* All iterations stacked */} +
+ {session.iterations.map((iter, idx) => ( + + {idx > 0 && ( +
- #{idx + 1}: {iter.instruction.substring(0, 40)} - {iter.instruction.length > 40 ? '...' : ''} - {iter.selected_proposal_index !== null && ( - - (selected #{iter.selected_proposal_index + 1}) - - )} -
- ))} -
-
- )} - - {/* Current instruction */} - {latestIteration && ( -
- - Instruction (iteration #{latestIteration.iteration_index + 1}): - -
{latestIteration.instruction}
-
- )} + /> + )} + + + ))} +
- {/* Error message */} - {latestIteration?.error_message && ( + {/* Sticky input bar */} + {showContinueInput && (
- {latestIteration.error_message} -
- )} - - {/* Progress indicator with log streaming */} - {(isInProgress || logStreamState.jobs.size > 0) && ( - - )} - - {/* Completed: show proposals */} - {iterationStatus === 'completed' && latestIteration && ( - <> - {latestIteration.before_screenshot_url && ( -
-

Before

-
- Before -
-
- )} - - {completedProposals.length > 0 && ( -
-

- Select a design ({completedProposals.length} proposals) -

-
- {completedProposals.map((proposal) => ( - +