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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

#### Agent Message Visual Differentiation (Issue #688)
- **Enhanced MessageItem component** (`frontend/src/components/threads/MessageItem.tsx:27-50, 59-66, 68-191, 211-245, 461-466, 530-550`):
- Agent detection logic using `getAgentDisplayData()` helper function
- `hexToRgba()` utility for generating color-tinted backgrounds from agent badge colors
- Distinct visual styling for agent messages vs user messages:
- **Background**: Subtle gradient using agent's badge color with low opacity (8% to 3%)
- **Border**: Colored border matching agent's badge color instead of default gray
- **Accent strip**: 4px colored left border (like highlighted messages) using agent color
- **Avatar**: Bot icon instead of User icon, with agent-colored gradient background
- **Box shadow**: Agent-colored shadow on avatar for visual consistency
- **Accessibility improvements**:
- Updated `aria-label` to include "(AI Agent)" suffix for screen readers
- Avatar `title` attribute identifies agent name and type
- Agent color sourced from `AgentConfiguration.badgeConfig.color` field (falls back to default blue #4A90E2)

#### Network Recovery on Screen Unlock (Issue #697)
- **New `useNetworkStatus` hook** (`frontend/src/hooks/useNetworkStatus.ts`): Monitors page visibility and network status changes to detect when the app resumes from background (e.g., screen unlock on mobile)
- **New `NetworkStatusHandler` component** (`frontend/src/components/network/NetworkStatusHandler.tsx`): Automatically refetches active Apollo Client queries when:
Expand Down
207 changes: 192 additions & 15 deletions frontend/src/components/threads/MessageItem.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import React, {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from "react";
import styled from "styled-components";
import {
User,
Bot,
MoreVertical,
Pin,
ChevronDown,
Expand All @@ -18,7 +25,7 @@ import { RelativeTime } from "./RelativeTime";
import { MessageBadges } from "../badges/MessageBadges";
import { MarkdownMessageRenderer } from "./MarkdownMessageRenderer";
import { formatUsername } from "./userUtils";
import { UserBadgeType } from "../../types/graphql-api";
import { UserBadgeType, AgentConfigurationType } from "../../types/graphql-api";
import {
mapWebSocketSourcesToChatMessageSources,
ChatMessageSource,
Expand All @@ -32,6 +39,62 @@ import {
DeleteMessageOutput,
} from "../../graphql/mutations";

/**
* Default color for agent messages when no badge color is configured
*/
const DEFAULT_AGENT_COLOR = "#4A90E2";

/**
* Validates that a value is a string
*/
function isString(value: unknown): value is string {
return typeof value === "string";
}

/**
* Validates that a string is a valid hex color (3 or 6 digit format)
*/
function isValidHexColor(value: string): boolean {
return /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/.test(value);
}

/**
* Helper to extract agent display data from configuration.
* Performs runtime validation of badgeConfig fields to ensure type safety
* since badgeConfig is a GenericScalar (essentially 'any') from GraphQL.
*/
interface AgentDisplayData {
name: string;
color: string;
}

export function getAgentDisplayData(
agentConfig: AgentConfigurationType | null | undefined
): AgentDisplayData | null {
if (!agentConfig) return null;

// Runtime validation: badgeConfig could be any shape from the database
const badgeConfig = agentConfig.badgeConfig;
let color = DEFAULT_AGENT_COLOR;

// Validate badgeConfig is an object and color is a valid hex string
if (
badgeConfig &&
typeof badgeConfig === "object" &&
!Array.isArray(badgeConfig)
) {
const configColor = (badgeConfig as Record<string, unknown>).color;
if (isString(configColor) && isValidHexColor(configColor)) {
color = configColor;
}
}

return {
name: agentConfig.name,
color,
};
}

interface MessageItemProps {
message: MessageNode;
isHighlighted?: boolean;
Expand All @@ -51,11 +114,54 @@ interface MessageItemProps {
onMessageDeleted?: () => void;
}

const MessageContainer = styled.div<{
$depth: number;
$isHighlighted?: boolean;
$isDeleted?: boolean;
}>`
/**
* Normalizes a 3-digit hex color to 6-digit format.
* e.g., "#abc" -> "#aabbcc"
*/
function normalizeHexColor(hex: string): string {
const shortHexMatch = /^#?([a-f\d])([a-f\d])([a-f\d])$/i.exec(hex);
if (shortHexMatch) {
return `#${shortHexMatch[1]}${shortHexMatch[1]}${shortHexMatch[2]}${shortHexMatch[2]}${shortHexMatch[3]}${shortHexMatch[3]}`;
}
return hex;
}

/**
* Helper to create an rgba color from a hex color with alpha.
* Supports both 3-digit (#abc) and 6-digit (#aabbcc) hex formats.
*/
export function hexToRgba(hex: string, alpha: number): string {
// Normalize 3-digit hex to 6-digit
const normalizedHex = normalizeHexColor(hex);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(
normalizedHex
);
if (!result) return `rgba(74, 144, 226, ${alpha})`; // Fallback to default blue
return `rgba(${parseInt(result[1], 16)}, ${parseInt(
result[2],
16
)}, ${parseInt(result[3], 16)}, ${alpha})`;
}

/**
* Pre-computed agent color values to avoid recalculation in styled-components
*/
interface AgentColorProps {
$agentColor?: string;
$agentBgStart?: string;
$agentBgEnd?: string;
$agentShadow?: string;
$agentShadowHover?: string;
}

const MessageContainer = styled.div<
{
$depth: number;
$isHighlighted?: boolean;
$isDeleted?: boolean;
$isAgent?: boolean;
} & AgentColorProps
>`
/* CRITICAL: Block-level display to prevent shrinking */
display: block;

Expand All @@ -76,12 +182,17 @@ const MessageContainer = styled.div<{
if (props.$isDeleted) return "#f3f4f6";
if (props.$isHighlighted)
return `linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 100%)`;
if (props.$isAgent && props.$agentBgStart && props.$agentBgEnd) {
// Use pre-computed RGBA values for performance
return `linear-gradient(135deg, ${props.$agentBgStart} 0%, ${props.$agentBgEnd} 100%)`;
}
return "#ffffff";
}};

border: 1px solid
${(props) => {
if (props.$isHighlighted) return "#3b82f6";
if (props.$isAgent) return props.$agentColor || "#4A90E2";
return "#d1d5db";
}};

Expand All @@ -91,6 +202,7 @@ const MessageContainer = styled.div<{
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.08);
position: relative;

/* Accent strip for highlighted messages */
${(props) =>
props.$isHighlighted &&
`
Expand All @@ -106,10 +218,33 @@ const MessageContainer = styled.div<{
}
`}

/* Accent strip for agent messages (Issue #688) */
${(props) =>
props.$isAgent &&
!props.$isHighlighted &&
`
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(180deg, ${
props.$agentColor || "#4A90E2"
} 0%, ${props.$agentColor || "#4A90E2"}88 100%);
border-radius: 16px 0 0 16px;
}
`}

&:hover {
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08), 0 4px 10px rgba(0, 0, 0, 0.05);
transform: translateY(-1px);
border-color: ${(props) => (props.$isHighlighted ? "#2563eb" : "#9ca3af")};
border-color: ${(props) => {
if (props.$isHighlighted) return "#2563eb";
if (props.$isAgent) return props.$agentColor || "#4A90E2";
return "#9ca3af";
}};
}

${(props) =>
Expand Down Expand Up @@ -164,24 +299,35 @@ const MessageHeaderLeft = styled.div`
min-width: 0;
`;

const UserAvatar = styled.div`
const UserAvatar = styled.div<{ $isAgent?: boolean } & AgentColorProps>`
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
background: ${(props) =>
props.$isAgent
? `linear-gradient(135deg, ${props.$agentColor || "#4A90E2"} 0%, ${
props.$agentColor || "#4A90E2"
}dd 100%)`
: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)"};
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.3);
box-shadow: ${(props) =>
props.$isAgent && props.$agentShadow
? `0 4px 14px ${props.$agentShadow}`
: "0 4px 14px rgba(99, 102, 241, 0.3)"};
transition: all 0.2s ease;

&:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.35);
box-shadow: ${(props) =>
props.$isAgent && props.$agentShadowHover
? `0 6px 16px ${props.$agentShadowHover}`
: "0 6px 16px rgba(102, 126, 234, 0.35)"};
}

@media (max-width: 480px) {
Expand Down Expand Up @@ -617,6 +763,25 @@ export const MessageItem = React.memo(function MessageItem({
message.creator?.email
);

// Detect if message is from an agent (Issue #688)
const agentData = useMemo(
() => getAgentDisplayData(message.agentConfiguration),
[message.agentConfiguration]
);
const isAgent = agentData !== null;

// Memoize RGBA color values to avoid recalculation in styled-components
const agentColors = useMemo(() => {
if (!agentData) return undefined;
return {
color: agentData.color,
bgStart: hexToRgba(agentData.color, 0.08),
bgEnd: hexToRgba(agentData.color, 0.03),
shadow: hexToRgba(agentData.color, 0.4),
shadowHover: hexToRgba(agentData.color, 0.5),
};
}, [agentData]);

// State for sources expansion and selection
const [sourcesExpanded, setSourcesExpanded] = useState(false);
const [selectedSourceIndex, setSelectedSourceIndex] = useState<
Expand Down Expand Up @@ -783,14 +948,26 @@ export const MessageItem = React.memo(function MessageItem({
$depth={message.depth}
$isHighlighted={isHighlighted}
$isDeleted={isDeleted}
$isAgent={isAgent}
$agentColor={agentColors?.color}
$agentBgStart={agentColors?.bgStart}
$agentBgEnd={agentColors?.bgEnd}
role="article"
aria-label={`Message from ${username}`}
aria-label={`Message from ${
isAgent ? `${agentData.name} (AI Agent)` : username
}`}
>
{/* Header */}
<MessageHeader>
<MessageHeaderLeft>
<UserAvatar title={username}>
<User size={16} />
<UserAvatar
title={isAgent ? `${agentData.name} (AI Agent)` : username}
$isAgent={isAgent}
$agentColor={agentColors?.color}
$agentShadow={agentColors?.shadow}
$agentShadowHover={agentColors?.shadowHover}
>
{isAgent ? <Bot size={18} /> : <User size={16} />}
</UserAvatar>
<UserInfo>
<UsernameRow>
Expand Down
Loading
Loading