Desktop UX polish: chat input, agent bulk actions, starting badge (#378)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-21 11:42:11 -06:00
committed by GitHub
parent 818b4885e2
commit 725dc351d7
26 changed files with 356 additions and 160 deletions

View File

@@ -245,10 +245,8 @@ fn check_moov_before_mdat(path: &Path) -> Result<(), MediaError> {
b"moov" => {
moov_seen = true;
}
b"mdat" => {
if !moov_seen {
return Err(MediaError::MoovNotAtFront);
}
b"mdat" if !moov_seen => {
return Err(MediaError::MoovNotAtFront);
}
_ => {}
}

View File

@@ -24,7 +24,9 @@
"hiddenTitle": true,
"dragDropEnabled": false,
"trafficLightPosition": { "x": 12, "y": 22 },
"backgroundThrottling": "disabled"
"backgroundThrottling": "disabled",
"minWidth": 800,
"minHeight": 500
}
],
"security": {

View File

@@ -124,12 +124,19 @@ export function AgentsView() {
}
logLoading={agents.managedAgentLogQuery.isLoading}
personaLabelsById={personas.personaLabelsById}
presenceLoaded={agents.managedPresenceQuery.isSuccess}
presenceLookup={agents.managedPresenceQuery.data ?? {}}
onAddToChannel={(agent) => {
agents.setActionNoticeMessage(null);
agents.setActionErrorMessage(null);
agents.setAgentToAddToChannel(agent);
}}
onBulkRemoveStopped={() => {
void agents.handleBulkRemoveStopped();
}}
onBulkStopRunning={() => {
void agents.handleBulkStopRunning();
}}
onCreate={() => {
agents.setIsCreateOpen(true);
}}

View File

@@ -16,6 +16,7 @@ import {
} from "lucide-react";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { Badge } from "@/shared/ui/badge";
import type {
ManagedAgent,
PresenceLookup,
@@ -43,6 +44,7 @@ export function ManagedAgentRow({
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
onAddToChannel,
onDelete,
@@ -60,6 +62,7 @@ export function ManagedAgentRow({
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
onAddToChannel: (agent: ManagedAgent) => void;
onDelete: (pubkey: string) => void;
@@ -116,7 +119,8 @@ export function ManagedAgentRow({
presenceStatus={presenceStatus}
/>
<StatusBlock
isActive={isActive}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
status={agent.status}
/>
@@ -135,7 +139,8 @@ export function ManagedAgentRow({
presenceStatus={presenceStatus}
/>
<StatusBlock
isActive={isActive}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
status={agent.status}
/>
@@ -215,9 +220,7 @@ function AgentSummary({
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-foreground">{agent.name}</p>
{personaLabel ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{personaLabel}
</span>
<Badge variant="secondary">{personaLabel}</Badge>
) : null}
<AgentOriginBadge agent={agent} />
</div>
@@ -234,12 +237,9 @@ function AgentSummary({
{channelNames.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{channelNames.map((name) => (
<span
className="inline-flex rounded-md bg-muted/60 px-1.5 py-0.5 text-[11px] text-muted-foreground"
key={name}
>
<Badge className="normal-case" key={name} variant="secondary">
# {name}
</span>
</Badge>
))}
</div>
) : null}
@@ -250,11 +250,13 @@ function AgentSummary({
}
function StatusBlock({
isActive,
presenceLoaded,
presenceStatus,
processDetail,
status,
}: {
isActive: boolean;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
processDetail: string;
status: ManagedAgent["status"];
}) {
@@ -263,7 +265,11 @@ function StatusBlock({
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground lg:hidden">
Status
</p>
<AgentStatusBadge isActive={isActive} status={status} />
<AgentStatusBadge
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={status}
/>
<p className="text-xs text-muted-foreground">{processDetail}</p>
</div>
);
@@ -444,29 +450,32 @@ function AgentActionsMenu({
function AgentOriginBadge({ agent }: { agent: ManagedAgent }) {
return (
<span className="rounded-full border border-border/70 bg-background/80 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<Badge variant="outline">
{agent.backend.type === "local" ? "Local" : "Remote"}
</span>
</Badge>
);
}
function AgentStatusBadge({
isActive,
presenceLoaded,
presenceStatus,
status,
}: {
isActive: boolean;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
status: ManagedAgent["status"];
}) {
const isActive = status === "running" || status === "deployed";
const isStarting =
presenceLoaded &&
status === "running" &&
(!presenceStatus || presenceStatus === "offline");
const variant = isStarting ? "warning" : isActive ? "default" : "secondary";
return (
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{status.replace(/_/g, " ")}
</span>
<Badge variant={variant}>
{isStarting ? "Starting\u2026" : status.replace(/_/g, " ")}
</Badge>
);
}

View File

@@ -1,4 +1,14 @@
import { Ellipsis, OctagonX, Trash2 } from "lucide-react";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Skeleton } from "@/shared/ui/skeleton";
import { CreateNewButton } from "./CreateNewButton";
import { ManagedAgentRow } from "./ManagedAgentRow";
@@ -15,8 +25,11 @@ export function ManagedAgentsSection({
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
onAddToChannel,
onBulkRemoveStopped,
onBulkStopRunning,
onCreate,
onDelete,
onMintToken,
@@ -37,8 +50,11 @@ export function ManagedAgentsSection({
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
onAddToChannel: (agent: ManagedAgent) => void;
onBulkRemoveStopped: () => void;
onBulkStopRunning: () => void;
onCreate: () => void;
onDelete: (pubkey: string) => void;
onMintToken: (pubkey: string, name: string) => void;
@@ -48,6 +64,10 @@ export function ManagedAgentsSection({
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
selectedLogAgentPubkey: string | null;
}) {
const runningCount = agents.filter((a) => isManagedAgentActive(a)).length;
const stoppedCount = agents.filter(
(a) => a.status === "stopped" || a.status === "not_deployed",
).length;
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
@@ -59,11 +79,47 @@ export function ManagedAgentsSection({
Agent profiles and process state — local and remote.
</p>
</div>
<CreateNewButton
ariaLabel="Create agent"
label="Agent"
onClick={onCreate}
/>
<div className="flex items-center gap-2">
{agents.length > 0 ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Bulk actions"
className="h-7 w-7"
size="icon"
variant="ghost"
>
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
disabled={isActionPending || runningCount === 0}
onClick={onBulkStopRunning}
>
<OctagonX className="h-4 w-4" />
Stop all running ({runningCount})
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isActionPending || stoppedCount === 0}
onClick={onBulkRemoveStopped}
>
<Trash2 className="h-4 w-4" />
Remove all stopped ({stoppedCount})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CreateNewButton
ariaLabel="Create agent"
label="Agent"
onClick={onCreate}
/>
</div>
</div>
{isLoading ? (
@@ -111,6 +167,7 @@ export function ManagedAgentsSection({
}
logLoading={selectedLogAgentPubkey === agent.pubkey && logLoading}
personaLabelsById={personaLabelsById}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
onAddToChannel={onAddToChannel}
onDelete={onDelete}

View File

@@ -4,6 +4,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { promptPreview } from "@/shared/lib/promptPreview";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type PersonaIdentityProps = {
@@ -35,9 +36,7 @@ export function PersonaIdentity({
{persona.displayName}
</p>
{showBuiltInBadge ? (
<span className="whitespace-nowrap rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Built-in
</span>
<Badge variant="secondary">Built-in</Badge>
) : null}
{showPromptTooltip && preview ? (
<Tooltip>

View File

@@ -9,6 +9,7 @@ import type {
} from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import {
@@ -425,9 +426,7 @@ export function TeamDialog({
/>
<span className="text-sm">{persona.displayName}</span>
{persona.isBuiltIn ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Built-in
</span>
<Badge variant="secondary">Built-in</Badge>
) : null}
</div>
);

View File

@@ -18,8 +18,11 @@ import type {
CreateManagedAgentResponse,
ManagedAgent,
} from "@/shared/api/types";
import { removeChannelMember } from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
deleteManagedAgentWithRules,
isManagedAgentActive,
startManagedAgentWithRules,
stopManagedAgentWithRules,
} from "../lib/managedAgentControlActions";
@@ -141,6 +144,22 @@ export function useManagedAgentActions() {
}
}
function getAgentChannelIds(pubkey: string): string[] {
const normalized = normalizePubkey(pubkey);
const relayAgent = (relayAgentsQuery.data ?? []).find(
(ra) => normalizePubkey(ra.pubkey) === normalized,
);
return relayAgent?.channelIds ?? [];
}
async function removeAgentFromAllChannels(pubkey: string) {
const channelIds = getAgentChannelIds(pubkey);
if (channelIds.length === 0) return;
await Promise.allSettled(
channelIds.map((channelId) => removeChannelMember(channelId, pubkey)),
);
}
async function handleDelete(pubkey: string) {
clearFeedback();
try {
@@ -154,6 +173,7 @@ export function useManagedAgentActions() {
relayAgents: relayAgentsQuery.data ?? [],
});
if (result.cancelled) return;
await removeAgentFromAllChannels(pubkey);
if (logAgentPubkey === pubkey) {
setLogAgentPubkey(null);
}
@@ -224,6 +244,74 @@ export function useManagedAgentActions() {
void relayAgentsQuery.refetch();
}
async function runBulkAction(
targets: ManagedAgent[],
confirmLabel: string,
failureNoun: string,
action: (agent: ManagedAgent) => Promise<unknown>,
): Promise<boolean> {
if (targets.length === 0) return false;
const confirmed = window.confirm(
`${confirmLabel} ${targets.length} agent${targets.length === 1 ? "" : "s"}?`,
);
if (!confirmed) return false;
clearFeedback();
const results = await Promise.allSettled(targets.map(action));
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
setActionErrorMessage(
`${failures.length} of ${targets.length} ${failureNoun}${failures.length === 1 ? "" : "s"} failed.`,
);
}
return true;
}
async function handleBulkStopRunning() {
await runBulkAction(
managedAgents.filter((a) => isManagedAgentActive(a)),
"Stop",
"stop",
(a) =>
stopManagedAgentWithRules({
agent: a,
channels: channelsQuery.data ?? [],
relayAgents: relayAgentsQuery.data ?? [],
stopManagedAgent: stopMutation.mutateAsync,
}),
);
}
async function handleBulkRemoveStopped() {
const targets = managedAgents.filter(
(a) => a.status === "stopped" || a.status === "not_deployed",
);
const executed = await runBulkAction(
targets,
"Remove",
"removal",
async (a) => {
await deleteManagedAgent(a);
await removeAgentFromAllChannels(a.pubkey);
},
);
if (
executed &&
logAgentPubkey &&
targets.some((a) => a.pubkey === logAgentPubkey)
) {
setLogAgentPubkey(null);
}
}
async function deleteManagedAgent(agent: ManagedAgent) {
const isDeployedRemote =
agent.backend.type === "provider" && agent.backendAgentId;
await deleteMutation.mutateAsync({
pubkey: agent.pubkey,
forceRemoteDelete: isDeployedRemote ? true : undefined,
});
}
const isPending =
startMutation.isPending ||
stopMutation.isPending ||
@@ -264,6 +352,8 @@ export function useManagedAgentActions() {
handleToggleStartOnAppLaunch,
handleMintToken,
handleAddedToChannel,
handleBulkStopRunning,
handleBulkRemoveStopped,
// Refetch helpers (for cross-domain use)
refetchManagedAgents: () => void managedAgentsQuery.refetch(),
refetchRelayAgents: () => void relayAgentsQuery.refetch(),

View File

@@ -17,6 +17,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Badge } from "@/shared/ui/badge";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
@@ -379,13 +380,9 @@ function ChannelCard({
<p className="text-sm font-semibold tracking-tight">
{channel.name}
</p>
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{channel.channelType}
</p>
<Badge variant="secondary">{channel.channelType}</Badge>
{channel.archivedAt ? (
<p className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
archived
</p>
<Badge variant="warning">archived</Badge>
) : null}
<div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-muted-foreground">

View File

@@ -228,12 +228,6 @@ export const ChannelPane = React.memo(function ChannelPane({
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}
/>
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
@@ -255,6 +249,12 @@ export const ChannelPane = React.memo(function ChannelPane({
: "Select a channel"
}
/>
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
</div>
{threadHeadMessage ? (

View File

@@ -29,6 +29,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Badge } from "@/shared/ui/badge";
type MembersSidebarMemberCardProps = {
canChangeRole: boolean;
@@ -119,12 +120,12 @@ export function MembersSidebarMemberCard({
{managedAgent ? (
<>
<span aria-hidden="true">&middot;</span>
<span
className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground"
<Badge
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
variant="secondary"
>
{formatManagedAgentStatus(managedAgent)}
</span>
</Badge>
</>
) : null}
</div>

View File

@@ -31,7 +31,7 @@ function HomeLoadingState() {
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
<div className="grid gap-4 xl:grid-cols-2">
<div className="grid gap-4">
{["mentions", "actions"].map((section) => (
<div key={section}>
<Skeleton className="mb-2 h-4 w-24" />
@@ -161,8 +161,6 @@ export function HomeView({
const showNeedsAction = filter === "all" || filter === "needs_action";
const showActivity = filter === "all" || filter === "activity";
const showAgentActivity = filter === "all" || filter === "agent_activity";
const singleColumn = filter !== "all";
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
@@ -193,7 +191,7 @@ export function HomeView({
) : null}
<React.Suspense fallback={null}>
<div className={`grid gap-5 ${singleColumn ? "" : "xl:grid-cols-2"}`}>
<div className="grid gap-5">
{showMentions ? (
<FeedSection
availableChannelIds={availableChannelIds}

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { Badge } from "@/shared/ui/badge";
import { cn } from "@/shared/lib/cn";
type ChannelAutocompleteProps = {
@@ -57,9 +58,7 @@ export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({
type="button"
>
<span className="truncate font-medium">#{suggestion.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{suggestion.channelType}
</span>
<Badge variant="secondary">{suggestion.channelType}</Badge>
</button>
))}
</div>

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { Badge } from "@/shared/ui/badge";
import { cn } from "@/shared/lib/cn";
export type MentionSuggestion = {
@@ -70,9 +71,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
({suggestion.personaName})
</span>
) : suggestion.role ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{suggestion.role}
</span>
<Badge variant="secondary">{suggestion.role}</Badge>
) : null}
</button>
))}

View File

@@ -17,7 +17,6 @@ import {
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerAttachments } from "./ComposerAttachments";
@@ -51,7 +50,6 @@ type MessageComposerProps = {
body: string;
id: string;
} | null;
showTopBorder?: boolean;
typingParentEventId?: string | null;
typingRootEventId?: string | null;
};
@@ -68,7 +66,6 @@ export function MessageComposer({
onSend,
placeholder,
replyTarget = null,
showTopBorder = true,
typingParentEventId = null,
typingRootEventId = null,
}: MessageComposerProps) {
@@ -499,15 +496,10 @@ export function MessageComposer({
// ── Render ──────────────────────────────────────────────────────────
return (
<footer
className={cn(
"bg-background p-4",
showTopBorder ? "border-t border-border/80" : "",
)}
>
<footer className="relative z-10 -mt-4 px-8 pb-0 pt-0 sm:px-10">
<div className="flex w-full flex-col gap-3">
<form
className="relative rounded-2xl border border-input bg-card px-3 py-4 sm:px-4"
className="relative rounded-2xl border border-input bg-card px-4 pb-3 pt-4"
data-testid="message-composer"
onDragOver={media.handleDragOver}
onDrop={(e) => {

View File

@@ -56,7 +56,7 @@ export const MessageComposerToolbar = React.memo(
sendDisabled: boolean;
}) {
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-1 py-1 min-h-11">
{/*
* AnimatePresence with mode="popLayout" — exiting elements

View File

@@ -6,6 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
@@ -338,9 +339,7 @@ export const MessageRow = React.memo(
{message.personaDisplayName}
</span>
) : message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
<Badge variant="secondary">{message.role}</Badge>
) : null}
{metadataNode}
</div>
@@ -360,9 +359,7 @@ export const MessageRow = React.memo(
{message.personaDisplayName}
</span>
) : message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
<Badge variant="secondary">{message.role}</Badge>
) : null}
{metadataNode}
</div>

View File

@@ -162,7 +162,7 @@ export function MessageThreadPanel({
</div>
<div
className="min-h-0 flex-1 overflow-y-auto"
className="min-h-0 flex-1 overflow-y-auto pb-6"
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
@@ -253,13 +253,7 @@ export function MessageThreadPanel({
</div>
) : null}
<div className="p-4">
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
<div>
<MessageComposer
channelId={channelId}
channelName={channelName}
@@ -269,10 +263,15 @@ export function MessageThreadPanel({
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
replyTarget={composerReplyTarget}
showTopBorder={false}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
</div>
</aside>
);

View File

@@ -104,7 +104,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
) : null}
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-6 pt-3 [overflow-anchor:none] sm:px-6"
data-scroll-restoration-id="message-timeline"
data-testid="message-timeline"
onScroll={syncScrollState}
@@ -168,7 +168,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<div className="pointer-events-none absolute inset-x-0 bottom-8 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
data-testid="message-scroll-to-latest"

View File

@@ -66,44 +66,44 @@ export function TypingIndicatorRow({
[channel, currentPubkey, profiles, typingPubkeys],
);
if (labels.length === 0) {
return null;
}
return (
<div
aria-live="polite"
className="bg-background/95 px-4 py-2 sm:px-6"
data-testid="message-typing-indicator"
className="h-8 bg-background px-8 sm:px-10"
{...(labels.length > 0
? { "data-testid": "message-typing-indicator" }
: {})}
>
<div className="flex w-full items-center gap-2">
<div className="flex flex-shrink-0 items-center">
{typingPubkeys.map((pubkey, index) => {
const profile = profiles?.[pubkey];
const label = labels[index] ?? pubkey.slice(0, 8);
return (
<div
key={pubkey}
className={`relative h-5 w-5 flex-shrink-0 rounded-full ring-1 ring-background${index > 0 ? " -ml-1.5" : ""}`}
data-testid="message-typing-avatar"
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
label={label}
className="h-5 w-5 rounded-full text-[8px]"
iconClassName="h-3 w-3"
/>
</div>
);
})}
{labels.length > 0 && (
<div className="flex w-full items-center gap-2 py-1.5">
<div className="flex flex-shrink-0 items-center">
{typingPubkeys.map((pubkey, index) => {
const profile = profiles?.[pubkey];
const label = labels[index] ?? pubkey.slice(0, 8);
return (
<div
key={pubkey}
className={`relative h-5 w-5 flex-shrink-0 rounded-full ring-1 ring-background${index > 0 ? " -ml-1.5" : ""}`}
data-testid="message-typing-avatar"
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
label={label}
className="h-5 w-5 rounded-full text-[8px]"
iconClassName="h-3 w-3"
/>
</div>
);
})}
</div>
<p
className="truncate text-sm text-muted-foreground"
data-testid="message-typing-indicator-label"
>
{formatTypingLabel(labels)}
</p>
</div>
<p
className="truncate text-sm text-muted-foreground"
data-testid="message-typing-indicator-label"
>
{formatTypingLabel(labels)}
</p>
</div>
)}
</div>
);
}

View File

@@ -23,6 +23,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Badge } from "@/shared/ui/badge";
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -339,9 +340,9 @@ export function SearchDialog({
<p className="text-sm font-semibold tracking-tight">
{hit.channelName}
</p>
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<Badge variant="secondary">
{describeSearchHit(hit)}
</p>
</Badge>
<p className="text-xs text-muted-foreground">
{authorLabel}
</p>

View File

@@ -219,7 +219,7 @@ export function DoctorSettingsPanel() {
</Button>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="mt-5 grid gap-4">
<div className="space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<div className="flex items-center gap-2">

View File

@@ -9,6 +9,7 @@ import {
} from "lucide-react";
import type { Workflow } from "@/shared/api/types";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -34,19 +35,16 @@ type WorkflowCardProps = {
};
function StatusBadge({ status }: { status: Workflow["status"] }) {
const colors = {
active: "bg-green-500/15 text-green-500",
disabled: "bg-muted text-muted-foreground",
archived: "bg-amber-500/15 text-amber-500",
const variants: Record<
Workflow["status"],
"success" | "secondary" | "warning"
> = {
active: "success",
disabled: "secondary",
archived: "warning",
};
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${colors[status]}`}
>
{status}
</span>
);
return <Badge variant={variants[status]}>{status}</Badge>;
}
export function WorkflowCard({

View File

@@ -9,6 +9,7 @@ import {
} from "@/features/workflows/hooks";
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
import type { Workflow } from "@/shared/api/types";
import { Badge, type BadgeProps } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import {
@@ -280,23 +281,21 @@ function formatStatusLabel(status: string) {
}
function RunStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
active: "bg-green-500/15 text-green-500",
disabled: "bg-muted text-muted-foreground",
archived: "bg-amber-500/15 text-amber-500",
completed: "bg-green-500/15 text-green-500",
failed: "bg-red-500/15 text-red-500",
running: "bg-blue-500/15 text-blue-500",
pending: "bg-muted text-muted-foreground",
cancelled: "bg-muted text-muted-foreground",
waiting_approval: "bg-amber-500/15 text-amber-500",
const variants: Record<string, BadgeProps["variant"]> = {
active: "success",
disabled: "secondary",
archived: "warning",
completed: "success",
failed: "destructive",
running: "info",
pending: "secondary",
cancelled: "secondary",
waiting_approval: "warning",
};
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] ${colors[status] ?? colors.pending}`}
>
<Badge variant={variants[status] ?? "secondary"}>
{formatStatusLabel(status)}
</span>
</Badge>
);
}

View File

@@ -1,6 +1,7 @@
import { Check, Clock, SkipForward, X } from "lucide-react";
import type { WorkflowApproval, WorkflowRun } from "@/shared/api/types";
import { Badge, type BadgeProps } from "@/shared/ui/badge";
import { WorkflowApprovalCard } from "@/features/workflows/ui/WorkflowApprovalCard";
type WorkflowRunTraceProps = {
@@ -12,6 +13,25 @@ function formatStatusLabel(status: string) {
return status.replace(/_/g, " ");
}
function StepStatusBadge({ status }: { status: string }) {
const variants: Record<string, BadgeProps["variant"]> = {
completed: "success",
failed: "destructive",
error: "destructive",
running: "info",
pending: "secondary",
cancelled: "secondary",
skipped: "secondary",
waiting_approval: "warning",
};
return (
<Badge variant={variants[status] ?? "secondary"}>
{formatStatusLabel(status)}
</Badge>
);
}
function StepStatusIcon({ status }: { status: string }) {
switch (status) {
case "completed":
@@ -65,9 +85,7 @@ export function WorkflowRunTrace({
<span className="min-w-0 flex-1 truncate font-mono text-xs font-medium">
{step.stepId}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{formatStatusLabel(step.status)}
</span>
<StepStatusBadge status={step.status} />
{duration ? (
<span className="text-xs text-muted-foreground">
{duration}

View File

@@ -0,0 +1,37 @@
import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2 pb-[3px] pt-[5px] text-[10px] font-semibold uppercase leading-none tracking-[0.18em]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-muted text-muted-foreground",
outline:
"border border-border/70 bg-background/80 text-muted-foreground",
destructive: "bg-destructive text-destructive-foreground",
warning: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
success: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
info: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };