mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
"hiddenTitle": true,
|
||||
"dragDropEnabled": false,
|
||||
"trafficLightPosition": { "x": 12, "y": 22 },
|
||||
"backgroundThrottling": "disabled"
|
||||
"backgroundThrottling": "disabled",
|
||||
"minWidth": 800,
|
||||
"minHeight": 500
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">·</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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
37
desktop/src/shared/ui/badge.tsx
Normal file
37
desktop/src/shared/ui/badge.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user