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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default defineConfig({
"**/channel-controls-screenshots.spec.ts",
"**/team-management-screenshots.spec.ts",
"**/active-turn-screenshots.spec.ts",
"**/profile-active-turn-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/video-attachment.spec.ts",
"**/mentions.spec.ts",
Expand Down
23 changes: 23 additions & 0 deletions desktop/src/features/profile/ui/UserProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
useRelayAgentsQuery,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog";
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
Expand Down Expand Up @@ -176,6 +178,18 @@ export function UserProfilePanel({
);
const isBot = Boolean(relayAgent || managedAgent);
const isOwner = useIsManagedAgent(isBot ? pubkey : null);

// Populate the active-turns store for this agent so useActiveAgentTurns works
// even if the Agents page hasn't been visited yet.
const bridgeAgents = React.useMemo(
() =>
managedAgent
? [{ pubkey: managedAgent.pubkey, status: managedAgent.status }]
: [],
[managedAgent],
);
useActiveAgentTurnsBridge(bridgeAgents);
useManagedAgentObserverBridge(bridgeAgents);
const canEditAgent = isOwner === true && managedAgent !== undefined;
const memoryQuery = useAgentMemoryQuery(pubkey, {
enabled: isOwner === true,
Expand All @@ -201,6 +215,14 @@ export function UserProfilePanel({
[pubkeyLower, relayAgent, managedAgent, channelsQuery.data],
);

const channelIdToName = React.useMemo(() => {
const map: Record<string, string> = {};
for (const channel of channelsQuery.data ?? []) {
map[channel.id] = channel.name;
}
return map;
}, [channelsQuery.data]);

const prevPubkeyRef = React.useRef(pubkey);
if (prevPubkeyRef.current !== pubkey) {
prevPubkeyRef.current = pubkey;
Expand Down Expand Up @@ -297,6 +319,7 @@ export function UserProfilePanel({
canEditAgent={canEditAgent}
canViewActivity={canViewActivity}
channelCount={profileChannels.length}
channelIdToName={channelIdToName}
channelsLoading={channelsQuery.isLoading}
displayName={displayName}
followMutation={followMutation}
Expand Down
48 changes: 48 additions & 0 deletions desktop/src/features/profile/ui/UserProfilePanelSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import { toast } from "sonner";

import { MemorySection } from "@/features/agent-memory/ui/MemorySection";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import type {
Expand All @@ -36,6 +39,8 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import { Badge } from "@/shared/ui/badge";

const RUNTIME_LABELS: Record<string, string> = {
goose: "Goose",
Expand All @@ -59,6 +64,7 @@ export type ProfileSummaryViewProps = {
canEditAgent: boolean;
canViewActivity: boolean;
channelCount: number;
channelIdToName: Record<string, string>;
channelsLoading: boolean;
displayName: string;
followMutation: ReturnType<typeof useFollowMutation>;
Expand Down Expand Up @@ -90,6 +96,7 @@ export function ProfileSummaryView({
canEditAgent,
canViewActivity,
channelCount,
channelIdToName,
channelsLoading,
displayName,
followMutation,
Expand All @@ -116,6 +123,9 @@ export function ProfileSummaryView({
unfollowMutation,
userStatus,
}: ProfileSummaryViewProps) {
const { goChannel } = useAppNavigation();
const activeTurns = useActiveAgentTurns(isBot ? pubkey : null);

const metadataFields = [
...buildPublicFields({
pubkey,
Expand Down Expand Up @@ -161,6 +171,20 @@ export function ProfileSummaryView({
/>
) : null}

{activeTurns.length > 0 ? (
<div className="flex flex-wrap justify-center gap-1.5">
{activeTurns.map(({ channelId, observedAt }) => (
<ProfileWorkingBadge
key={channelId}
channelId={channelId}
name={channelIdToName[channelId] ?? channelId}
observedAt={observedAt}
onNavigate={goChannel}
/>
))}
</div>
) : null}

{showMemoriesIngress || showChannelsIngress || canViewActivity ? (
<section className="space-y-2">
{showMemoriesIngress ? (
Expand Down Expand Up @@ -211,6 +235,30 @@ export function ProfileSummaryView({
);
}

function ProfileWorkingBadge({
channelId,
name,
observedAt,
onNavigate,
}: {
channelId: string;
name: string;
observedAt: number;
onNavigate: (channelId: string) => void;
}) {
const now = useNow(1000);

return (
<Badge
className="cursor-pointer motion-safe:animate-pulse normal-case tracking-normal hover:opacity-80"
variant="default"
onClick={() => onNavigate(channelId)}
>
Working in #{name} · {formatElapsed(now - observedAt)}
</Badge>
);
}

// ── Hero & metadata ──────────────────────────────────────────────────────────

function ProfileHero({
Expand Down
41 changes: 41 additions & 0 deletions desktop/src/features/profile/ui/UserProfilePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
useRelayAgentsQuery,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUserStatusQuery } from "@/features/user-status/hooks";
import { useChannelsQuery } from "@/features/channels/hooks";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
Expand All @@ -16,6 +19,7 @@ import { useProfilePanel } from "@/shared/context/ProfilePanelContext";

import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import { useNow } from "@/shared/lib/useNow";

type UserProfilePopoverProps = {
children: React.ReactNode;
Expand Down Expand Up @@ -90,6 +94,15 @@ export function UserProfilePopover({
const profile = profileQuery.data;
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null);
const channelsQuery = useChannelsQuery();
const channelIdToName = React.useMemo(() => {
const map: Record<string, string> = {};
for (const channel of channelsQuery.data ?? []) {
map[channel.id] = channel.name;
}
return map;
}, [channelsQuery.data]);

const clearHoverTimer = React.useCallback(() => {
if (hoverTimerRef.current !== null) {
Expand Down Expand Up @@ -243,6 +256,18 @@ export function UserProfilePopover({
</div>
) : null}

{activeTurns.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{activeTurns.map(({ channelId, observedAt }) => (
<PopoverWorkingBadge
key={channelId}
name={channelIdToName[channelId] ?? channelId}
observedAt={observedAt}
/>
))}
</div>
) : null}

{profile?.about ? (
<p className="text-xs leading-relaxed text-muted-foreground">
{profile.about}
Expand All @@ -268,3 +293,19 @@ export function UserProfilePopover({
</Popover>
);
}

function PopoverWorkingBadge({
name,
observedAt,
}: {
name: string;
observedAt: number;
}) {
const now = useNow(1000);

return (
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary motion-safe:animate-pulse">
Working in #{name} · {formatElapsed(now - observedAt)}
</span>
);
}
19 changes: 18 additions & 1 deletion desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2169,7 +2169,24 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
sig: "mocksig".repeat(20).slice(0, 128),
},
]
: [];
: channelId === "94a444a4-c0a3-5966-ab05-530c6ddc2301"
? [
// Charlie is a `bot` member of #agents (see channel seed), so this
// message renders with role="bot" — the surface whose avatar opens
// a managed-agent profile panel / hover popover with active-turn
// badges. #agents has no message-row index assertions, so seeding
// here is safe for existing specs.
{
id: "mock-agents-charlie",
pubkey: CHARLIE_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - 90,
kind: 9,
tags: [["h", channelId]],
content: "Indexing the channel catalog now.",
sig: "mocksig".repeat(20).slice(0, 128),
},
]
: [];

mockMessages.set(channelId, seeded);
return seeded;
Expand Down
Loading