Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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 @@ -35,6 +35,7 @@ export default defineConfig({
"**/active-turn-resilience.spec.ts",
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/video-attachment.spec.ts",
Expand Down
13 changes: 12 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,18 @@ const overrides = new Map([
["src/shared/ui/markdown.tsx", 2119],
["src/shared/ui/VideoPlayer.tsx", 2199],
["src/shared/ui/sidebar.tsx", 1042],
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
// permission-outcome (fix #1381 regression): pendingPermissions state map,
// describePermissionOutcome helper, jsonRpcId key helper (handles both
// string and finite-number JSON-RPC ids per spec), and the acp_write
// response correlation branch are all tightly coupled to the existing
// request handler. Load-bearing logic growth, not generic debt. Queued to
// split into a dedicated permission module in the next transcript refactor.
// +123: observer parity — 4 new named session/update classifier cases
// (current_mode_update, usage_update, available_commands_update,
// config_option_update) + replaceLifecycleItem helper for usage coalescing +
// system-prompt ordering fix (turnId: null for per-channel items).
// Load-bearing feature growth; queued to split in next transcript refactor.
["src/features/agents/ui/agentSessionTranscript.ts", 1167],
// catalog module; agent_models.rs retains the thin wrapper (~50 lines).
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
// fallback. Queued to split into dedicated discovery modules.
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/features/agents/observerRelayStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,25 @@ export function useManagedAgentObserverBridge(
}, [queryClient]);
}

/**
* E2E-only: inject synthetic observer events directly into the store, bypassing
* the relay-security knownAgentPubkeys filter. Exercises the real
* appendAgentEvent → processTranscriptEvent ingestion path so screenshot specs
* prove the production render, not a stub.
*
* Never call this from production code — it is intentionally not re-exported
* from the public agent feature barrel.
*/
export function injectObserverEventsForE2E(
agentPubkey: string,
events: ObserverEvent[],
) {
for (const event of events) {
appendAgentEvent(agentPubkey, event);
}
notifyListeners();
}

export function resetAgentObserverStore() {
generation += 1;
const unsubscribe = unsubscribeRelay;
Expand Down
182 changes: 51 additions & 131 deletions desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import * as React from "react";
import { CheckCheck, ChevronDown, Radio } from "lucide-react";
import { CheckCheck, Radio } from "lucide-react";

import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import type { TranscriptItem } from "./agentSessionTypes";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
import {
ActivityRow,
Expand Down Expand Up @@ -207,6 +206,7 @@ function TranscriptTurnSegmentView({
context={segment.context}
profiles={profiles}
setup={segment.setup}
systemPrompt={segment.systemPrompt}
user={segment.user}
/>
);
Expand Down Expand Up @@ -355,11 +355,13 @@ function TurnPromptBlock({
context,
profiles,
setup,
systemPrompt,
user,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
profiles?: UserProfileLookup;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
user: Extract<TranscriptItem, { type: "message" }>;
}) {
return (
Expand All @@ -377,6 +379,7 @@ function TurnPromptBlock({
item={user}
profiles={profiles}
setup={setup}
systemPrompt={systemPrompt}
/>
</div>
);
Expand All @@ -387,145 +390,95 @@ function PromptUserMessage({
item,
profiles,
setup = [],
systemPrompt = null,
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
systemPrompt?: Extract<TranscriptItem, { type: "metadata" }> | null;
}) {
const [contextOpen, setContextOpen] = React.useState(false);

return (
<>
<UserMessageBubble
bubbleClassName="p-2.5"
footer={
<TurnSetupFooter
context={context}
contextOpen={contextOpen}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
<PromptContextDialog
context={context}
onOpenChange={setContextOpen}
open={contextOpen}
setup={setup}
/>
{systemPrompt && systemPrompt.sections.length > 0 ? (
<PromptContextInline context={systemPrompt} />
) : null}
{context && context.sections.length > 0 ? (
<PromptContextInline context={context} />
) : null}
</>
);
}

function PromptContextSections({
className,
sections,
}: {
className?: string;
sections: PromptSection[];
}) {
return (
<div
className={cn("space-y-3", className)}
data-testid="transcript-prompt-context-sections"
>
{sections.map((section) => (
<PromptContextSectionAccordion
key={`${section.title}:${section.body.slice(0, 48)}`}
section={section}
/>
))}
</div>
);
}

function PromptContextSectionAccordion({
section,
function PromptContextInline({
context,
}: {
section: PromptSection;
context: Extract<TranscriptItem, { type: "metadata" }>;
}) {
const [open, setOpen] = React.useState(false);
const body = section.body.trim();
const [dialogOpen, setDialogOpen] = React.useState(false);

return (
<article className="overflow-hidden rounded-2xl bg-muted/40">
<button
aria-expanded={open}
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen((value) => !value)}
type="button"
<>
<div
className="mt-1 space-y-2 pl-2"
data-testid="transcript-prompt-context-inline"
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
>
{section.title}
</div>
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
)}
>
{body.length > 0 ? (
body
) : (
<span className="italic text-foreground/50">No metadata.</span>
)}
</div>
</div>
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground/70">
{context.title}
</p>
<button
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
data-testid="transcript-prompt-context-expand"
onClick={() => setDialogOpen(true)}
type="button"
>
View full
</button>
</div>
</button>
</article>
<PromptContextSections sections={context.sections} />
</div>
<PromptContextDialog
context={context}
onOpenChange={setDialogOpen}
open={dialogOpen}
/>
</>
);
}

function PromptContextDialog({
context,
onOpenChange,
open,
setup,
}: {
context: Extract<TranscriptItem, { type: "metadata" }> | null;
context: Extract<TranscriptItem, { type: "metadata" }>;
onOpenChange: (open: boolean) => void;
open: boolean;
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || !context || context.sections.length === 0) {
if (!open || context.sections.length === 0) {
return null;
}

const setupText = formatPromptSetupSummary(setup);

return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (
<div className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DialogDescription>{setupText}</DialogDescription>
</div>
) : null}
<DialogTitle>{context.title}</DialogTitle>
</DialogHeader>

<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<PromptContextSections sections={context.sections} />
</div>
Expand All @@ -535,70 +488,37 @@ function PromptContextDialog({
);
}

function formatPromptSetupSummary(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
return [label, detail].filter(Boolean).join(" · ");
}

function TurnSetupFooter({
context = null,
contextOpen = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
contextOpen?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
messageLink?: { channelId: string; messageId: string } | null;
onContextOpenChange?: (open: boolean) => void;
showTimestamp?: boolean;
timestamp: string;
}) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
const tooltipText = [label, detail].filter(Boolean).join(" · ");
const showSetup = items.length > 0;
const showContext = context != null && context.sections.length > 0;

if (!showSetup && !showContext) {
if (!showSetup) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
}

const contextToggle = showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
data-testid="transcript-prompt-context-toggle"
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : null;

return (
<div
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
{showContext && showSetup ? contextToggle : null}
{!showContext && showSetup ? (
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
) : null}
{showContext && !showSetup ? contextToggle : null}
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
{showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null}
Expand Down
Loading
Loading