Skip to content
Closed
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 @@ -24,6 +24,7 @@ export default defineConfig({
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",
"**/mentions.spec.ts",
"**/smart-links.spec.ts",
"**/workflows.spec.ts",
],
use: {
Expand Down
160 changes: 153 additions & 7 deletions desktop/src/features/messages/ui/TypingIndicatorRow.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import * as React from "react";

import {
useManagedAgentLogQuery,
useManagedAgentsQuery,
useStopManagedAgentMutation,
} from "@/features/agents/hooks";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { Channel } from "@/shared/api/types";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";

type TypingIndicatorRowProps = {
channel: Channel | null;
Expand Down Expand Up @@ -46,12 +52,123 @@ function formatTypingLabel(names: string[]) {
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`;
}

function formatElapsed(startIso: string): string {
const startMs = new Date(startIso).getTime();
const nowMs = Date.now();
const totalSeconds = Math.max(0, Math.floor((nowMs - startMs) / 1000));

if (totalSeconds >= 3600) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}

if (totalSeconds >= 60) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}

return `${totalSeconds}s`;
}

/** Compact ACP log preview — dark terminal block, last N lines. */
function AgentLogPreview({ pubkey }: { pubkey: string }) {
const { data: logData, isLoading } = useManagedAgentLogQuery(pubkey, 10);

if (isLoading) {
return (
<div className="mt-2 rounded-lg bg-[#17171d] px-3 py-2 text-[11px] text-zinc-500">
Loading log…
</div>
);
}

const trimmed = logData?.content?.trim();
if (!trimmed) {
return null;
}

return (
<div className="mt-2 overflow-hidden rounded-lg border border-white/5 bg-[#17171d]">
<pre
className="max-h-[8rem] overflow-auto whitespace-pre-wrap px-3 py-2 font-mono text-[11px] leading-relaxed text-zinc-300"
data-testid="typing-popover-log"
>
{trimmed}
</pre>
</div>
);
}

type BotTypingPopoverContentProps = {
botAgents: ManagedAgent[];
};

function BotTypingPopoverContent({ botAgents }: BotTypingPopoverContentProps) {
const [, setTick] = React.useState(0);
const mutation = useStopManagedAgentMutation();

React.useEffect(() => {
const interval = setInterval(() => {
setTick((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);

function handleInterrupt() {
for (const agent of botAgents) {
mutation.mutate(agent.pubkey);
}
}

return (
<div>
{botAgents.map((agent) => (
<div key={agent.pubkey} className="mb-3 last:mb-0">
<div className="flex items-baseline justify-between gap-2">
<div className="font-bold text-sm truncate">{agent.name}</div>
<div className="flex-shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{agent.lastStartedAt ? formatElapsed(agent.lastStartedAt) : "—"}
</div>
</div>
{agent.model && (
<div className="text-xs text-muted-foreground">{agent.model}</div>
)}
<AgentLogPreview pubkey={agent.pubkey} />
</div>
))}
<button
type="button"
className="mt-3 w-full rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
data-testid="typing-interrupt-button"
disabled={mutation.isPending}
onClick={handleInterrupt}
>
{mutation.isPending ? "Interrupting…" : "Interrupt"}
</button>
</div>
);
}

export function TypingIndicatorRow({
channel,
currentPubkey,
profiles,
typingPubkeys,
}: TypingIndicatorRowProps) {
const { data: managedAgents } = useManagedAgentsQuery();

const managedAgentMap = React.useMemo(() => {
const map = new Map<string, ManagedAgent>();
if (managedAgents) {
for (const agent of managedAgents) {
map.set(agent.pubkey.toLowerCase(), agent);
}
}
return map;
}, [managedAgents]);

const labels = React.useMemo(
() =>
typingPubkeys.map((pubkey) =>
Expand All @@ -66,10 +183,22 @@ export function TypingIndicatorRow({
[channel, currentPubkey, profiles, typingPubkeys],
);

const botAgents = React.useMemo(
() =>
typingPubkeys
.map((pubkey) => managedAgentMap.get(pubkey.toLowerCase()))
.filter((agent): agent is ManagedAgent => agent !== undefined),
[typingPubkeys, managedAgentMap],
);

const hasBotTypers = botAgents.length > 0;

if (labels.length === 0) {
return null;
}

const typingText = formatTypingLabel(labels);

return (
<div
aria-live="polite"
Expand Down Expand Up @@ -97,12 +226,29 @@ export function TypingIndicatorRow({
);
})}
</div>
<p
className="truncate text-sm text-muted-foreground"
data-testid="message-typing-indicator-label"
>
{formatTypingLabel(labels)}
</p>
{hasBotTypers ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="truncate text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
data-testid="message-typing-indicator-label"
>
{typingText}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-96 p-3">
<BotTypingPopoverContent botAgents={botAgents} />
</PopoverContent>
</Popover>
) : (
<p
className="truncate text-sm text-muted-foreground"
data-testid="message-typing-indicator-label"
>
{typingText}
</p>
)}
</div>
</div>
);
Expand Down
78 changes: 67 additions & 11 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CircleDot, GitCommitHorizontal, GitPullRequest } from "lucide-react";
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
Expand All @@ -11,6 +12,15 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkMentions from "@/shared/lib/remarkMentions";

const GITHUB_PR_RE =
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/;

const GITHUB_ISSUE_RE =
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/;

const GITHUB_COMMIT_RE =
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/commit\/([0-9a-f]{7,40})\/?$/;

type MarkdownProps = {
channelNames?: string[];
className?: string;
Expand Down Expand Up @@ -41,17 +51,63 @@ function createMarkdownComponents(
: "space-y-1 pl-6 marker:text-muted-foreground";

return {
a: ({ children, href, ...props }) => (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
),
a: ({ children, href, ...props }) => {
if (href) {
let Icon: React.ComponentType<{ className?: string }> | null = null;
let label: string | null = null;

const prMatch = GITHUB_PR_RE.exec(href);
if (prMatch) {
const [, owner, repo, number] = prMatch;
Icon = GitPullRequest;
label = `${owner}/${repo}#${number}`;
}

const issueMatch = !Icon ? GITHUB_ISSUE_RE.exec(href) : null;
if (issueMatch) {
const [, owner, repo, number] = issueMatch;
Icon = CircleDot;
label = `${owner}/${repo}#${number}`;
}

const commitMatch = !Icon ? GITHUB_COMMIT_RE.exec(href) : null;
if (commitMatch) {
const [, owner, repo, sha] = commitMatch;
Icon = GitCommitHorizontal;
label = `${owner}/${repo}@${sha.slice(0, 7)}`;
}

if (Icon && label) {
return (
<a
{...props}
className="relative inline-flex items-center gap-1 rounded-md bg-primary/10 px-1.5 py-0.5 text-sm font-medium text-primary no-underline transition-colors hover:bg-primary/20"
href={href}
rel="noreferrer"
target="_blank"
>
<span className="pointer-events-none select-none inline-flex items-center gap-1" aria-hidden="true">
<Icon className="size-3.5" />
{label}
</span>
<span className="absolute w-0 overflow-hidden whitespace-nowrap">{href}</span>
</a>
);
}
}

return (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground">
{children}
Expand Down
Loading
Loading