mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
**Category:** feat **User Impact:** At wide viewports, opening an agent's activity inside a channel now covers the channel content area in a right-anchored drawer instead of squeezing into a ~380px split pane, so tool calls, diffs, and command output are readable without resizing. **Problem:** The agent session panel shared the thread's split `RightAuxiliaryPane`, which is too narrow for a transcript. The focus drawer that solves this for threads was welded to thread-specific breadcrumb and view-mode-toggle concerns, so activity could not reuse it. **Solution:** Extract the drawer surface into a presentation-only `CoverDrawer` and give activity its own thin wrapper. Activity always covers at wide viewports and offers no focus/split toggle; narrow, overlay, and single-panel presentations are unchanged for both surfaces. A single resolved auxiliary surface makes the two drawers mutually exclusive by construction, so last-opened wins and two drawers can never stack. Thread drawer behavior is unchanged. <details> <summary>File changes</summary> **desktop/src/features/channels/ui/CoverDrawer.tsx** New presentation-only drawer extracted from `FocusThreadDrawer`: motion, scrim, focus capture/restore, and an `ownsEscape` opt-out for content that already handles Escape itself. **desktop/src/features/channels/ui/AgentActivityDrawer.tsx** Activity's wrapper. Delegates Escape to the panel so the settings menu dismisses first. **desktop/src/features/channels/ui/FocusThreadDrawer.tsx** Reduced to a `CoverDrawer` wrapper plus its thread-specific focus-restore rule. Behavior unchanged. **desktop/src/features/channels/lib/channelAuxiliarySurface.ts** Resolves the one auxiliary surface and which surface, if any, covers — the structural guarantee that only one drawer exists. **desktop/src/features/channels/lib/agentSessionPanelPresentation.ts** Maps presentation to the panel's layout props. Suppresses the panel's own enter motion inside the drawer to avoid a double slide. **desktop/src/features/channels/lib/coverDrawerLayout.ts** Sliver width and travel distance, moved off the thread-specific module. **desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx** Accepts and forwards `enterMotion` (additive), which `AuxiliaryPanel` already supported but the panel never threaded through. **desktop/src/features/channels/ui/ChannelPane.tsx** Renders branches off the resolved surface instead of an implicit fall-through chain, and wraps the agent panel in the cover drawer. **desktop/tests/e2e/agent-activity-cover.spec.ts** Covers covering vs. splitting, drawer exclusivity, suppressed panel motion, Escape/scrim dismissal, and unchanged narrow presentation. **desktop/tests/e2e/activity-scope-label-screenshots.spec.ts** Lengthens the long-name fixture so the header truncation assertion still clamps at the wider cover-drawer width. </details> Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz> Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
570 lines
20 KiB
TypeScript
570 lines
20 KiB
TypeScript
import * as React from "react";
|
|
import {
|
|
Clock3,
|
|
Octagon,
|
|
Settings,
|
|
Sparkles,
|
|
TerminalSquare,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
|
|
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
|
import {
|
|
mergeObserverEventWindows,
|
|
observerEventScrollId,
|
|
scopeByChannel,
|
|
} from "@/features/agents/ui/agentSessionPanelLayout";
|
|
import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping";
|
|
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
|
|
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
|
|
import {
|
|
useArchivedChannelEvents,
|
|
useObserverEvents,
|
|
} from "@/features/agents/ui/useObserverEvents";
|
|
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
|
|
import { useStableArrayShallow } from "@/shared/hooks/useStableReference";
|
|
import { cancelManagedAgentTurn } from "@/shared/api/agentControl";
|
|
import type { Channel } from "@/shared/api/types";
|
|
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
|
|
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
|
|
import { useNow } from "@/shared/lib/useNow";
|
|
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
|
|
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
|
|
import {
|
|
AuxiliaryPanelHeader,
|
|
AuxiliaryPanelHeaderActions,
|
|
AuxiliaryPanelHeaderGroup,
|
|
} from "@/shared/layout/AuxiliaryPanel";
|
|
import { Button } from "@/shared/ui/button";
|
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
|
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
|
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/shared/ui/dropdown-menu";
|
|
import { Switch } from "@/shared/ui/switch";
|
|
import {
|
|
setTranscriptAnimationEnabled,
|
|
useTranscriptAnimationEnabled,
|
|
} from "@/features/agents/ui/transcriptAnimationPreference";
|
|
import {
|
|
setTranscriptTimestampsEnabled,
|
|
useTranscriptTimestampsEnabled,
|
|
} from "@/features/agents/ui/transcriptTimestampPreference";
|
|
import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents";
|
|
import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll";
|
|
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
|
|
import { useChannelReference } from "@/features/channels/openChannelDirectory";
|
|
|
|
type AgentSessionThreadPanelProps = {
|
|
agent: ChannelAgentSessionAgent;
|
|
channel: Channel | null;
|
|
channelId?: string | null;
|
|
canInterruptTurn: boolean;
|
|
/**
|
|
* When false, the panel skips its own slide-in. Set by the cover drawer,
|
|
* which already animates itself, so the two don't compound into a double
|
|
* slide. Defaults to animating.
|
|
*/
|
|
enterMotion?: boolean;
|
|
layout?: "standalone" | "split";
|
|
isSinglePanelView?: boolean;
|
|
profiles?: UserProfileLookup;
|
|
/**
|
|
* Fired by the header back arrow. Restores the pane this panel replaced
|
|
* (thread or profile) via the captured return target — see
|
|
* useChannelAgentSessions.backFromAgentSession. Omit when there is no
|
|
* target (composer/no-pane open, direct/restored URL): the arrow hides
|
|
* and the close affordance is the fallback.
|
|
*/
|
|
onBack?: () => void;
|
|
onClose: () => void;
|
|
widthPx: number;
|
|
transparentChrome?: boolean;
|
|
};
|
|
|
|
export function AgentSessionThreadPanel({
|
|
agent,
|
|
canInterruptTurn,
|
|
channel,
|
|
channelId = null,
|
|
enterMotion = true,
|
|
layout = "standalone",
|
|
isSinglePanelView = false,
|
|
profiles,
|
|
onBack,
|
|
onClose,
|
|
widthPx,
|
|
transparentChrome = false,
|
|
}: AgentSessionThreadPanelProps) {
|
|
const isLive = isManagedAgentActive(agent);
|
|
const isOverlay = useIsThreadPanelOverlay();
|
|
const sessionChannelId = channelId ?? channel?.id ?? null;
|
|
// Unified working signal, scoped to this panel's channel (or all channels
|
|
// when the panel is unscoped) — observer turns primary, typing fallback.
|
|
const { working: isWorking } = useAgentWorking(
|
|
agent.pubkey,
|
|
sessionChannelId,
|
|
);
|
|
const canStopCurrentTurn = isWorking && canInterruptTurn;
|
|
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
|
|
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
|
const now = useNow(1000);
|
|
const { connectionState, events } = useObserverEvents(isLive, agent.pubkey);
|
|
const scopedEvents = React.useMemo(
|
|
() => scopeByChannel(events, sessionChannelId),
|
|
[events, sessionChannelId],
|
|
);
|
|
// Archived channel events merged with live scoped events so the header's
|
|
// "Last updated" timestamp reflects the full loaded history, not just the
|
|
// capped live window. Mirrors ManagedAgentSessionPanel's combinedEvents.
|
|
const archivedChannelEvents = useArchivedChannelEvents(
|
|
agent.pubkey,
|
|
sessionChannelId,
|
|
);
|
|
const combinedHeaderEvents = React.useMemo(
|
|
() => mergeObserverEventWindows(scopedEvents, archivedChannelEvents),
|
|
[scopedEvents, archivedChannelEvents],
|
|
);
|
|
const latestActivityAt = React.useMemo(
|
|
() => getLatestActivityTimestamp(combinedHeaderEvents),
|
|
[combinedHeaderEvents],
|
|
);
|
|
const lastUpdatedLabel = formatLastUpdatedLabel(latestActivityAt, now);
|
|
const lastUpdatedTitle =
|
|
latestActivityAt === null
|
|
? undefined
|
|
: `Last updated ${new Date(latestActivityAt).toLocaleString()}`;
|
|
|
|
const { fetchOlderArchived, hasOlderArchived } =
|
|
useLoadArchivedObserverEvents(
|
|
// Archive history must load regardless of live status — an idle agent's
|
|
// channel should still show its archived observer history. Enable whenever
|
|
// there is a resolved sessionChannelId (the hook's owner_p guard handles
|
|
// the case where no save subscription exists).
|
|
Boolean(sessionChannelId),
|
|
sessionChannelId ?? null,
|
|
);
|
|
|
|
useLoadOlderOnScroll({
|
|
fetchOlder: fetchOlderArchived,
|
|
hasOlderMessages: hasOlderArchived,
|
|
isLoading: false,
|
|
scrollContainerRef: scrollRef,
|
|
sentinelRef: topSentinelRef,
|
|
});
|
|
const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`;
|
|
const [rawFeedState, setRawFeedState] = React.useState(() => ({
|
|
scopeKey: rawFeedScopeKey,
|
|
show: false,
|
|
}));
|
|
const showRawFeed =
|
|
rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show;
|
|
const handleRawFeedChange = React.useCallback(
|
|
(checked: boolean) => {
|
|
setRawFeedState({ scopeKey: rawFeedScopeKey, show: checked });
|
|
},
|
|
[rawFeedScopeKey],
|
|
);
|
|
|
|
// --- Transcript block ids for default Activity mode ---
|
|
// Derive the same display-block keys the inner AgentSessionTranscriptList
|
|
// renders as `data-message-id` so useAnchoredScroll anchors on real DOM rows
|
|
// instead of raw event ids (which live in a disjoint namespace and cause
|
|
// per-event floor writes → flicker + jump-to-tail).
|
|
//
|
|
// latestLiveSessionId is omitted: it only affects boundary `labelState`,
|
|
// never keys (agentSessionTranscriptGrouping.ts:557-574), so we avoid
|
|
// subscribing to the observer store from the outer panel.
|
|
const transcriptBlockIds = React.useMemo(
|
|
() => (showRawFeed ? [] : deriveTranscriptBlockIds(combinedHeaderEvents)),
|
|
[combinedHeaderEvents, showRawFeed],
|
|
);
|
|
|
|
// Stabilize the id array by VALUE so the hook's restoration effect (keyed on
|
|
// the `messages` reference) does not fire on every raw event when the block
|
|
// id sequence is unchanged. useStableArrayShallow shallow-compares with
|
|
// Object.is on each string element.
|
|
const stableTranscriptBlockIds = useStableArrayShallow(transcriptBlockIds);
|
|
|
|
// Map to {id} objects only when the stabilized array reference changes.
|
|
const transcriptScrollMessages = React.useMemo(
|
|
() => stableTranscriptBlockIds.map((id) => ({ id })),
|
|
[stableTranscriptBlockIds],
|
|
);
|
|
|
|
// Raw-mode ids: keyed on (seq, timestamp) — matches RawEventRail's
|
|
// data-message-id. seq resets on agent restart so bare seq can collide;
|
|
// observerEventScrollId disambiguates.
|
|
const rawScrollMessages = React.useMemo(
|
|
() =>
|
|
combinedHeaderEvents.map((event) => ({
|
|
id: observerEventScrollId(event),
|
|
})),
|
|
[combinedHeaderEvents],
|
|
);
|
|
|
|
const { onScroll } = useAnchoredScroll({
|
|
// Fold view mode into the reset key so toggling raw ↔ transcript
|
|
// re-initializes the anchor (clean re-pin) instead of carrying an anchor
|
|
// across disjoint id namespaces.
|
|
channelId: `${rawFeedScopeKey}:${showRawFeed ? "raw" : "transcript"}`,
|
|
contentRef,
|
|
isLoading: connectionState === "connecting",
|
|
messages: showRawFeed ? rawScrollMessages : transcriptScrollMessages,
|
|
scrollContainerRef: scrollRef,
|
|
});
|
|
// Scope label input: prefer the passed channel's name; when the pane is
|
|
// channel-scoped without a full Channel object (#1380's channelId prop),
|
|
// resolve that one id through the bounded reference query.
|
|
const referencedChannel = useChannelReference(sessionChannelId);
|
|
const scopeChannelName =
|
|
channel && channel.id === sessionChannelId
|
|
? channel.name
|
|
: (referencedChannel?.name ?? null);
|
|
const scopeLabel = sessionChannelId
|
|
? scopeChannelName
|
|
? `#${scopeChannelName}`
|
|
: "1 channel"
|
|
: "All channels";
|
|
const agentProfile = profiles?.[normalizePubkey(agent.pubkey)] ?? null;
|
|
const agentLabel = resolveUserLabel({
|
|
pubkey: agent.pubkey,
|
|
fallbackName: agent.name,
|
|
profiles,
|
|
preferResolvedSelfLabel: true,
|
|
});
|
|
const viewLabel = showRawFeed ? "Raw ACP activity" : "Activity";
|
|
const headerScopeLabel = `${viewLabel} · ${scopeLabel}`;
|
|
const animateActivity = useTranscriptAnimationEnabled();
|
|
const showTimestamps = useTranscriptTimestampsEnabled();
|
|
async function handleInterruptTurn() {
|
|
if (!channel) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await cancelManagedAgentTurn(agent.pubkey, channel.id);
|
|
toast.success(
|
|
`Stop signal sent to ${agent.name}. It may take a moment to respond.`,
|
|
);
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: `Failed to stop ${agent.name}'s current turn.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const agentHeaderActions = (
|
|
<AuxiliaryPanelHeaderActions>
|
|
{isLive ? (
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
aria-label="Open activity settings"
|
|
className="relative"
|
|
data-testid="agent-session-settings-menu-trigger"
|
|
size="icon"
|
|
title="Activity settings"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<Settings />
|
|
{canStopCurrentTurn ? (
|
|
<span
|
|
aria-hidden="true"
|
|
className="absolute right-1 bottom-1 h-2 w-2 rounded-full bg-primary ring-2 ring-background"
|
|
data-testid="agent-session-settings-live-badge"
|
|
/>
|
|
) : null}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="min-w-56"
|
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
>
|
|
<DropdownMenuItem
|
|
className="items-start gap-3"
|
|
data-testid="agent-session-toggle-raw-feed"
|
|
onSelect={(event) => {
|
|
event.preventDefault();
|
|
handleRawFeedChange(!showRawFeed);
|
|
}}
|
|
title={
|
|
showRawFeed
|
|
? "Hide raw JSON-RPC payloads."
|
|
: channel
|
|
? "Show raw JSON-RPC payloads for this channel."
|
|
: "Show raw JSON-RPC payloads for this agent."
|
|
}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-center gap-2 text-sm font-medium">
|
|
<TerminalSquare className="h-4 w-4 text-muted-foreground" />
|
|
Raw
|
|
</span>
|
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
Show raw JSON-RPC activity.
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
aria-hidden="true"
|
|
checked={showRawFeed}
|
|
className="pointer-events-none mt-0.5"
|
|
tabIndex={-1}
|
|
/>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="items-start gap-3"
|
|
data-testid="agent-session-toggle-animate-activity"
|
|
disabled={showRawFeed}
|
|
onSelect={(event) => {
|
|
event.preventDefault();
|
|
setTranscriptAnimationEnabled(!animateActivity);
|
|
}}
|
|
title={
|
|
showRawFeed
|
|
? "Raw activity rows don't animate in."
|
|
: animateActivity
|
|
? "Stop animating new activity rows."
|
|
: "Animate new activity rows as they arrive."
|
|
}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-center gap-2 text-sm font-medium">
|
|
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
|
Show Animations
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
aria-hidden="true"
|
|
checked={animateActivity && !showRawFeed}
|
|
className="pointer-events-none mt-0.5"
|
|
tabIndex={-1}
|
|
/>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="items-start gap-3"
|
|
data-testid="agent-session-toggle-show-timestamps"
|
|
onSelect={(event) => {
|
|
event.preventDefault();
|
|
setTranscriptTimestampsEnabled(!showTimestamps);
|
|
}}
|
|
title={
|
|
showTimestamps
|
|
? "Hide per-row activity timestamps."
|
|
: "Show a timestamp under each activity row."
|
|
}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex items-center gap-2 text-sm font-medium">
|
|
<Clock3 className="h-4 w-4 text-muted-foreground" />
|
|
Show Timestamps
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
aria-hidden="true"
|
|
checked={showTimestamps}
|
|
className="pointer-events-none mt-0.5"
|
|
tabIndex={-1}
|
|
/>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="items-start gap-3"
|
|
data-testid="agent-session-stop-turn"
|
|
disabled={!canStopCurrentTurn}
|
|
onSelect={() => {
|
|
void handleInterruptTurn();
|
|
}}
|
|
title={
|
|
canStopCurrentTurn
|
|
? "Interrupt the current ACP turn without stopping the agent process."
|
|
: isWorking
|
|
? "Only locally managed agents can be interrupted from this community."
|
|
: "Available while the agent is working."
|
|
}
|
|
>
|
|
<Octagon className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-medium">
|
|
Stop current turn
|
|
</span>
|
|
{!canStopCurrentTurn ? (
|
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
{isWorking
|
|
? "Only available for locally managed agents."
|
|
: "Available while the agent is working."}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</AuxiliaryPanelHeaderActions>
|
|
);
|
|
|
|
const agentHeaderContent = (
|
|
<>
|
|
<AuxiliaryPanelHeaderGroup
|
|
align="start"
|
|
backButtonAriaLabel="Back from activity"
|
|
backButtonTestId="agent-session-back"
|
|
onBack={onBack}
|
|
>
|
|
<ProfileAvatar
|
|
avatarUrl={agentProfile?.avatarUrl ?? null}
|
|
className="size-9"
|
|
label={agentLabel}
|
|
testId="agent-session-agent-avatar"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<h2
|
|
className="truncate text-sm font-semibold leading-5"
|
|
data-testid="agent-session-agent-name"
|
|
title={agentLabel}
|
|
>
|
|
{agentLabel}
|
|
</h2>
|
|
<div className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
|
|
<p
|
|
className="min-w-0 flex-1 truncate"
|
|
data-testid="agent-session-scope-label"
|
|
>
|
|
{headerScopeLabel}
|
|
</p>
|
|
<span aria-hidden="true" className="shrink-0">
|
|
·
|
|
</span>
|
|
<span
|
|
className="shrink-0"
|
|
data-testid="agent-session-recency-label"
|
|
title={lastUpdatedTitle}
|
|
>
|
|
{lastUpdatedLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</AuxiliaryPanelHeaderGroup>
|
|
{agentHeaderActions}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<AuxiliaryPanel
|
|
enterMotion={enterMotion}
|
|
isSinglePanelView={isSinglePanelView}
|
|
layout={layout}
|
|
onClose={onClose}
|
|
testId="agent-session-thread-panel"
|
|
transparentChrome={transparentChrome}
|
|
widthPx={widthPx}
|
|
header={
|
|
<AuxiliaryPanelHeader
|
|
backdrop={layout !== "split" && !isOverlay}
|
|
backdropSurface="soft"
|
|
inset={layout !== "split" ? "wide" : "default"}
|
|
>
|
|
{agentHeaderContent}
|
|
</AuxiliaryPanelHeader>
|
|
}
|
|
>
|
|
<AuxiliaryPanelBody
|
|
ref={scrollRef}
|
|
onScroll={onScroll}
|
|
className="overflow-y-auto px-3 pb-4"
|
|
panelPadding
|
|
>
|
|
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
|
<div ref={contentRef}>
|
|
<ManagedAgentSessionPanel
|
|
agent={agent}
|
|
channelId={sessionChannelId}
|
|
className="border-0 bg-transparent px-0 py-2 shadow-none"
|
|
emptyDescription={
|
|
sessionChannelId
|
|
? `Mention ${agent.name} in the channel to see its work here.`
|
|
: `Mention ${agent.name} in any channel to see its work here.`
|
|
}
|
|
profiles={profiles}
|
|
rawLayout="exclusive"
|
|
showHeader={false}
|
|
showRaw={showRawFeed}
|
|
/>
|
|
</div>
|
|
</AuxiliaryPanelBody>
|
|
</AuxiliaryPanel>
|
|
);
|
|
}
|
|
|
|
function getLatestActivityTimestamp(
|
|
events: readonly ObserverEvent[],
|
|
): number | null {
|
|
let latest: number | null = null;
|
|
|
|
const record = (timestamp: string) => {
|
|
const parsed = Date.parse(timestamp);
|
|
if (!Number.isFinite(parsed)) {
|
|
return;
|
|
}
|
|
|
|
if (latest === null || parsed > latest) {
|
|
latest = parsed;
|
|
}
|
|
};
|
|
|
|
for (const event of events) {
|
|
record(event.timestamp);
|
|
}
|
|
|
|
return latest;
|
|
}
|
|
|
|
function formatLastUpdatedLabel(timestamp: number | null, now: number): string {
|
|
if (timestamp === null) {
|
|
return "No updates yet";
|
|
}
|
|
|
|
return `Last updated ${formatRelativeActivityTime(timestamp, now)}`;
|
|
}
|
|
|
|
function formatRelativeActivityTime(timestamp: number, now: number): string {
|
|
const elapsedMs = Math.max(0, now - timestamp);
|
|
const totalSeconds = Math.floor(elapsedMs / 1_000);
|
|
|
|
if (totalSeconds < 60) {
|
|
return "just now";
|
|
}
|
|
|
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
if (totalMinutes < 60) {
|
|
return `${totalMinutes}m ago`;
|
|
}
|
|
|
|
const totalHours = Math.floor(totalMinutes / 60);
|
|
if (totalHours < 24) {
|
|
return `${totalHours}h ago`;
|
|
}
|
|
|
|
const totalDays = Math.floor(totalHours / 24);
|
|
if (totalDays < 7) {
|
|
return `${totalDays}d ago`;
|
|
}
|
|
|
|
const totalWeeks = Math.floor(totalDays / 7);
|
|
return `${totalWeeks}w ago`;
|
|
}
|