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
25 changes: 25 additions & 0 deletions .claude/rules/17-ui-visual-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,31 @@ actionable URL, verification code, token, or confirmation:
4. Staging validation must prove the actionable value came through the real
integration, then exercise the native control that exposes it.

## Responsive / On-Demand Surfaces Must Be Proved Visible

When a user action opens a responsive surface (rail, drawer, popover, composer,
details panel), tests must prove the resulting surface is visible at the
viewport where the action is available. Do not rely on state-only assertions or
mobile-only inline panels when desktop CSS hides that panel.

Retained incident lesson (2026-08-25): desktop chat message comments regressed
because the message and selected-text `Comment` controls set draft state, but
the only composer lived in an inline panel hidden at `lg`. The action looked
clickable and the state updated, while users saw nothing. The fix restored an
on-demand desktop rail and added tests that click the real header, message-level,
and selected-text controls at 1280x800 and assert the rail/composer becomes
visible.

Required for any responsive/on-demand surface:

1. Assert the surface is hidden by default when that is part of the intended UX.
2. Click every public entry point that should open it, at the viewport where each
entry point is rendered.
3. Assert the visible surface contains the active/draft state created by the
action, not just that a boolean or ARIA attribute changed.
4. Include mobile and desktop screenshots when the surface changes form across
breakpoints.

## Virtualized-List Scroll/Jump Features (jsdom Renders All Rows — Assert the Coordinate)

When a feature scrolls or jumps to a specific item in a **virtualized** list
Expand Down
117 changes: 117 additions & 0 deletions apps/web/src/components/project-message-view/FloatingHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { classifyFailure } from '@simple-agent-manager/shared';

import type { SessionSourceContext } from '../../pages/project-chat/lineageUtils';
import { TruncatedSummary } from '../chat/TruncatedSummary';
import { FailureCard } from '../debug/FailureCard';
import { SessionHeader } from './SessionHeader';
import type { useSessionLifecycle } from './useSessionLifecycle';

interface FloatingHeaderProps {
projectId: string;
lc: ReturnType<typeof useSessionLifecycle>;
onSessionMutated?: () => void;
onRetry?: () => void;
onFork?: () => void;
onOpenTimeline?: () => void;
onOpenComments?: () => void;
unresolvedCommentCount?: number;
needsAttentionCommentCount?: number;
sourceContext?: SessionSourceContext;
onShowHierarchy?: (taskId: string) => void;
containerRef?: (el: HTMLDivElement | null) => void;
}

/** Floating session header with optional error banner and summary. */
export function FloatingHeader({
projectId,
lc,
onSessionMutated,
onRetry,
onFork,
onOpenTimeline,
onOpenComments,
unresolvedCommentCount,
needsAttentionCommentCount,
sourceContext,
onShowHierarchy,
containerRef,
}: FloatingHeaderProps) {

Check warning on line 38 in apps/web/src/components/project-message-view/FloatingHeader.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AaA5-Tcl0hcBejaCdfaG&open=AaA5-Tcl0hcBejaCdfaG&pullRequest=1907
if (!lc.session) return null;

const initialPromptFallback = !lc.hasMore
? (lc.messages.find((msg) => msg.role === 'user')?.content ?? null)
: null;
const taskStatus = lc.taskEmbed?.status;
const hasRecoverableTaskError = Boolean(
lc.taskEmbed?.errorMessage &&
lc.taskEmbed?.taskMode === 'conversation' &&
taskStatus !== 'failed' &&
taskStatus !== 'cancelled' &&
taskStatus !== 'completed'
);
const failureClassification = lc.taskEmbed?.errorMessage
? classifyFailure(lc.taskEmbed.errorMessage, lc.taskEmbed.executionStep ?? undefined)
: null;
const failureShellClassName = failureClassification?.diagnosable
? "glass-chrome px-3 py-2 rounded-b-2xl relative after:content-[''] after:absolute after:bottom-0 after:left-[8%] after:right-[8%] after:h-[3px] after:bg-[radial-gradient(ellipse_at_center,rgba(239,68,68,0.55)_0%,transparent_70%)] after:blur-[2px] after:pointer-events-none after:z-10"
: 'glass-chrome px-3 py-2 rounded-b-2xl relative';
const failureShellBoxShadow = failureClassification?.diagnosable
? '0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(239, 68, 68, 0.08)'
: '0 4px 24px rgba(0, 0, 0, 0.4)';

return (
<div ref={containerRef} className="absolute top-0 left-0 right-0 z-10">
<SessionHeader
projectId={projectId}
session={lc.session}
sessionState={lc.sessionState}
loading={lc.loading}
idleCountdownMs={lc.idleCountdownMs}
taskEmbed={lc.taskEmbed}
workspace={lc.workspace}
node={lc.node}
detectedPorts={lc.detectedPorts}
onSessionMutated={onSessionMutated}
onOpenFiles={lc.handleOpenFileBrowser}
onOpenGit={lc.handleOpenGitChanges}
onOpenTimeline={onOpenTimeline}
onOpenComments={onOpenComments}
unresolvedCommentCount={unresolvedCommentCount}
needsAttentionCommentCount={needsAttentionCommentCount}
onRetry={onRetry}
onFork={onFork}
lineageText={sourceContext?.lineageText}
initialPromptFallback={initialPromptFallback}
sourceContext={sourceContext}
hasContentBelow={!!lc.taskEmbed?.errorMessage}
onShowHierarchy={onShowHierarchy}
/>
{lc.taskEmbed?.errorMessage && (
<div
data-testid="failure-card-shell"
className={failureShellClassName}
style={{ boxShadow: failureShellBoxShadow }}
>
<div
aria-hidden="true"
className="absolute inset-0 rounded-[inherit] -z-10 pointer-events-none"
style={{
backgroundColor: 'color-mix(in srgb, var(--sam-color-bg-canvas) 78%, transparent)',
}}
/>
<FailureCard
projectId={projectId}
taskEmbed={lc.taskEmbed}
sessionId={lc.session?.id}
workspaceId={lc.workspace?.id ?? lc.session?.workspaceId}
nodeId={lc.node?.id ?? lc.workspace?.nodeId}
recoverable={hasRecoverableTaskError}
/>
</div>
)}
{lc.taskEmbed?.outputSummary && (
<TruncatedSummary summary={lc.taskEmbed.outputSummary} taskId={lc.taskEmbed.id} />
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button } from '@simple-agent-manager/ui';
import { useEffect, useState } from 'react';
import { forwardRef, type HTMLAttributes, useEffect, useState } from 'react';

/**
* Data the virtualized list's Header needs, threaded through Virtuoso's `context`
Expand Down Expand Up @@ -42,8 +42,13 @@ function ChatListHeader({ context }: { context?: ChatListContext }) {
);
}

const ChatListScroller = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
(props, ref) => <div {...props} ref={ref} data-sam-conversation-scroller="true" />
);
ChatListScroller.displayName = 'ChatListScroller';

/** Stable `components` object — see `ChatListHeader` for why this must not be inline. */
export const CHAT_LIST_COMPONENTS = { Header: ChatListHeader };
export const CHAT_LIST_COMPONENTS = { Header: ChatListHeader, Scroller: ChatListScroller };

/**
* Measures the floating header's rendered height so the message list can pad
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export interface MessageCommentRowState {
activeMessageId: string | null;
focusedCommentId: string | null;
draft: MessageCommentDraft | null;
detachedDraftMessageId: string | null;
actions: CommentActions;
onToggleMessageComments: (messageId: string) => void;
onSelectMessageComments: (messageId: string, commentId?: string) => void;
onStartComment: (draft: MessageCommentDraft) => void;
onCloseMessageComments: () => void;
onClearDraft: () => void;
Expand Down Expand Up @@ -58,7 +60,9 @@ export function CommentableConversationItem({
const hasUnresolvedComments = itemComments.some((comment) => comment.status !== 'resolved');
const isCommentsExpanded = commentState.activeMessageId === item.id;
const draftForMessage = commentState.draft?.anchorId === item.id ? commentState.draft : null;
const inlineComments = isCommentsExpanded || draftForMessage ? itemComments : [];
const inlineDraftForMessage =
commentState.detachedDraftMessageId === item.id ? null : draftForMessage;
const inlineComments = isCommentsExpanded || inlineDraftForMessage ? itemComments : [];
const commentAccentClass =
itemComments.length === 0
? ''
Expand Down Expand Up @@ -103,7 +107,7 @@ export function CommentableConversationItem({
<InlineMessageComments
messageId={item.id}
comments={inlineComments}
draft={draftForMessage}
draft={inlineDraftForMessage}
focusedCommentId={commentState.focusedCommentId}
actions={commentState.actions}
onClose={commentState.onCloseMessageComments}
Expand Down
Loading
Loading