Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/app/model/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions backend/app/router/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/app/schema/session_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/app/usecase/session_usecase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
16 changes: 10 additions & 6 deletions docker/worker-analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
}

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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"):

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text filter text.startswith("Searching") may suppress meaningful LLM output that genuinely begins with "Searching", such as "Searching for the relevant component..." or other contextual analysis messages from Claude. Since the elif tool_name in ("Glob", "Grep"): emit_log(...) branch was already removed from _emit_tool_detail, the "Searching: {pattern}" log lines from that branch are already gone. The additional string-prefix filter in the TextBlock loop is overly broad and could silently drop real diagnostic information from Claude's reasoning output.

Consider using a more targeted condition, such as checking for the exact phrases generated by MCP tool output (e.g., checking for "Browser action" patterns using a more specific prefix or regex) instead of the broad startswith("Searching").

Suggested change
if text.startswith("Browser") or text.startswith("Searching"):
if text.startswith("Browser"):

Copilot uses AI. Check for mistakes.
continue
emit_log(phase, text[:200], detail=block.text)
elif isinstance(block, ToolUseBlock):
_emit_tool_detail(phase, block.name, json.dumps(block.input))

Expand Down Expand Up @@ -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)
Comment on lines +374 to +376

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the generate_proposals function, the continue statement at line 375 skips both the emit_log call AND the collected_text.append(block.text) call (line 377). This means that if Claude outputs a text block that starts with "Browser" or "Searching" (e.g., "Searching through the codebase, I found: {...json...}"), the proposals JSON embedded in that text block will never be appended to collected_text and will be silently lost, causing _extract_proposals_json to find no JSON and return an empty proposals list.

The filter should only skip the emit_log call, not the collected_text.append. Consider separating the log-suppression from the collection logic.

Suggested change
if text.startswith("Browser") or text.startswith("Searching"):
continue
emit_log("analyzing", text[:200], detail=block.text)
suppress_log = text.startswith("Browser") or text.startswith("Searching")
if not suppress_log:
emit_log("analyzing", text[:200], detail=block.text)

Copilot uses AI. Check for mistakes.
collected_text.append(block.text)
elif isinstance(block, ToolUseBlock):
_emit_tool_detail("analyzing", block.name, json.dumps(block.input))
Expand Down
16 changes: 10 additions & 6 deletions docker/worker-implement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
}

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Comment on lines +175 to +178

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same overly broad startswith("Searching") filter as in _process_messages. The filter may suppress meaningful Claude output in the implement_changes function as well.

Copilot uses AI. Check for mistakes.
elif isinstance(block, ToolUseBlock):
_emit_tool_detail("implementing", block.name, json.dumps(block.input))

Expand Down Expand Up @@ -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"):

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same overly broad startswith("Searching") filter issue as in worker-analyze.py. The filter may suppress meaningful LLM output that starts with "Searching", since any genuine Claude reasoning text beginning with that word will be silently dropped from the log stream.

Suggested change
if text.startswith("Browser") or text.startswith("Searching"):
if text.startswith("Browser"):

Copilot uses AI. Check for mistakes.
continue
emit_log(phase, text[:200], detail=block.text)
elif isinstance(block, ToolUseBlock):
_emit_tool_detail(phase, block.name, json.dumps(block.input))

Expand Down
5 changes: 3 additions & 2 deletions docker/worker.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 && \

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded path /usr/lib/node_modules/@playwright/mcp for the cd command assumes that npm install -g always installs to /usr/lib/node_modules. This path is specific to Debian/Ubuntu with NodeSource packages, but may differ in other environments (e.g., Alpine Linux uses /usr/local/lib/node_modules, or if the Node.js prefix changes). If the path is wrong, npx playwright install chromium will fail silently or install Chromium in an unexpected location.

A more reliable approach is to use $(npm root -g)/@playwright/mcp instead of the hardcoded path: cd $(npm root -g)/@playwright/mcp.

Suggested change
cd /usr/lib/node_modules/@playwright/mcp && \
cd "$(npm root -g)"/@playwright/mcp && \

Copilot uses AI. Check for mistakes.
npx playwright install chromium

# Install GitHub CLI
Expand Down
26 changes: 23 additions & 3 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Session[]>([])
const [headerExtra, setHeaderExtra] = useState<ReactNode | null>(null)

const refreshSessions = useCallback(() => {
listSessions()
Expand Down Expand Up @@ -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 && (
Expand All @@ -55,11 +63,23 @@ export default function Layout() {
<span style={{ fontSize: '30px', color: '#9ca3af' }}>☰</span>
</button>
)}
<span style={{ fontSize: '26px', fontWeight: 600, color: 'rgba(255,255,255,0.87)' }}>
<span
style={{
fontSize: '26px',
fontWeight: 600,
color: 'rgba(255,255,255,0.87)',
marginRight: '16px',
}}
>
UI Recommender
</span>
{headerExtra && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', marginTop: '4px' }}>
{headerExtra}
</div>
)}
</div>
<Outlet context={{ refreshSessions } satisfies LayoutContext} />
<Outlet context={{ refreshSessions, setHeaderExtra } satisfies LayoutContext} />
</div>
</div>
)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/LogPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,8 @@ export default function LogPanel({ logState, defaultCollapsed }: LogPanelProps)
{/* Expanded content */}
{expanded && (
<div>
{/* Tab bar (show only if multiple jobs) */}
{jobKeys.length > 1 && (
{/* Tab bar */}
{jobKeys.length > 0 && (
<div
style={{
display: 'flex',
Expand Down
30 changes: 23 additions & 7 deletions frontend/src/components/ProposalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,49 @@ type ProposalCardProps = {
proposal: Proposal
selected: boolean
onToggle: (index: number) => 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 (
<div
style={{
border: selected ? '3px solid #3b82f6' : '1px solid #e5e7eb',
border: `${borderWidth} solid ${borderColor}`,
borderRadius: '8px',
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: selected ? '#eff6ff' : '#fff',
cursor: readOnly ? 'default' : 'pointer',
backgroundColor: bgColor,
transition: 'border-color 0.15s, box-shadow 0.15s',
boxShadow: selected ? '0 0 0 2px rgba(59,130,246,0.3)' : 'none',
boxShadow: shadow,
}}
onClick={() => onToggle(proposal.proposal_index)}
onClick={readOnly ? undefined : () => onToggle(proposal.proposal_index)}
>
<img
src={proposal.after_screenshot_url}
alt={proposal.title}
style={{ width: '100%', display: 'block' }}
/>
<div style={{ padding: '10px 12px' }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600, color: '#111' }}>
#{proposal.proposal_index + 1}: {proposal.title}
</h3>
</div>
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ export default function Sidebar({ sessions, isOpen, onToggle }: SidebarProps) {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #374151',
}}
>
<img
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ export default function StatusBadge({ status }: StatusBadgeProps) {
<span
style={{
display: 'inline-block',
padding: '2px 10px',
padding: '4px 12px',
borderRadius: '12px',
fontSize: '13px',
fontSize: '14px',
fontWeight: 600,
backgroundColor: style.bg,
color: style.text,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/hooks/useLayoutContext.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useOutletContext } from 'react-router-dom'
import type { ReactNode } from 'react'

type LayoutContext = {
refreshSessions: () => void
setHeaderExtra: (content: ReactNode | null) => void
}

export function useLayoutContext() {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading