diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 0035c5e9f..25e3127a0 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -18,6 +18,25 @@ export const CHAT_AUTOMATION_TAG: [string, string] = [ "work-panel", ]; +/** Tag for one automation prompt, carrying its kind for the marker row. */ +export function chatAutomationTag(kind: "ci" | "comments"): string[] { + return [...CHAT_AUTOMATION_TAG, kind]; +} + +/** Marker-row label for an automation message's tag. */ +export function chatAutomationLabel( + tag: readonly string[] | undefined, + agentName: string, +) { + if (tag?.[2] === "ci") { + return `Asked ${agentName} to fix the CI failures`; + } + if (tag?.[2] === "comments") { + return `Asked ${agentName} to address the review comments`; + } + return `Sent ${agentName} automation instructions`; +} + export type ChatWorkAutomation = { autoFixCi: boolean; addressComments: boolean; diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index a0ad5e838..96d6af663 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import { Bot, FolderGit2, MessageCircle, Power } from "lucide-react"; +import { Bot, FolderGit2, MessageCircle, Power, Wand2 } from "lucide-react"; +import { chatAutomationLabel } from "@/features/chats/lib/chatWorkAutomation"; import { cleanAssistantMessageText } from "@/features/chats/ui/chatActivityText"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { RelayEvent } from "@/shared/api/types"; @@ -168,6 +169,42 @@ export function ChatScrollAnchor({ return null; } +/** + * Marker row for an invisible automation prompt (auto-fix CI / address + * comments): the instruction itself stays out of the timeline, but the spot + * where it fired needs an anchor — total invisibility made "Run now" feel + * like it did nothing. Expandable to the full instruction text. + */ +export function ChatAutomationRow({ + agentName, + event, +}: { + agentName: string; + event: RelayEvent; +}) { + const tag = event.tags.find((candidate) => candidate[0] === "automation"); + return ( + + +
+ + + + {chatAutomationLabel(tag, agentName)} + + +
+ {event.content} +
+
+
+
+ ); +} + export function ChatContextRow({ event }: { event: RelayEvent }) { const isProjectSetup = event.content.startsWith("Project setup"); const projectSetupContent = event.content diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 73ec971ab..e45bdab92 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -27,7 +27,10 @@ import { NO_PROJECT_SELECTION_ID, } from "@/features/chats/lib/chatSetup"; import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; -import { CHAT_AUTOMATION_TAG } from "@/features/chats/lib/chatWorkAutomation"; +import { + CHAT_AUTOMATION_TAG, + chatAutomationTag, +} from "@/features/chats/lib/chatWorkAutomation"; import { deriveBranchFromAgentMessages, deriveChatWorkBranch, @@ -38,6 +41,7 @@ import { isHumanFacingAssistantText } from "@/features/chats/ui/chatActivityText import { entranceClassForCreatedAt } from "@/features/chats/ui/messageEntrance"; import { AgentActivationCard, + ChatAutomationRow, ChatContextRow, ChatMessageRow, ChatScrollAnchor, @@ -501,6 +505,24 @@ export function ChatDetail({ setIsActivationPending(true); onActivateAgent(); }, [onActivateAgent]); + + // Automation prompts must not fail silently: surface send errors, and if + // the default agent is stopped, start it too — the backlog replay delivers + // the prompt once it connects. + const handleAutomationPrompt = React.useCallback( + (content: string, kind: "ci" | "comments") => { + onSend(content, [], [chatAutomationTag(kind)]).catch((error: unknown) => { + console.error("Failed to send automation prompt", error); + toast.error("Could not send the automation instructions"); + }); + if (defaultAgent != null && !isManagedAgentActive(defaultAgent)) { + setIsActivationPending(true); + onActivateAgent(); + } + }, + [defaultAgent, onActivateAgent, onSend], + ); + React.useEffect(() => { if (!isActivationPending) { return; @@ -637,7 +659,12 @@ export function ChatDetail({ )} messageId={message.id} > - {isAutomationMessage ? null : isContextMessage ? ( + {isAutomationMessage ? ( + + ) : isContextMessage ? ( ) : ( - void onSend(content, [], [CHAT_AUTOMATION_TAG]) - } + onAutomationPrompt={handleAutomationPrompt} open={showWorkPanel} prHref={workPanelHref} projectPath={metadata?.projectPath ?? selectedProject?.path ?? null} diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 553b1b38d..574f6f7bc 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -68,7 +68,7 @@ export function ChatWorkPanel({ chatId: string; /** Whether an agent turn is currently running in this chat. */ isTurnActive?: boolean; - onAutomationPrompt?: (content: string) => void; + onAutomationPrompt?: (content: string, kind: "ci" | "comments") => void; open?: boolean; prHref?: string | null; /** Project directory — enables PR discovery by branch via the git remote. */ @@ -146,6 +146,7 @@ export function ChatWorkPanel({ }); onAutomationPrompt( `CI is failing on ${effectiveHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, + "ci", ); // The prompt itself is invisible in the timeline — acknowledge it. toast.success(`Asked ${agentName} to fix the CI failures`); @@ -160,6 +161,7 @@ export function ChatWorkPanel({ }); onAutomationPrompt( `There are unanswered review comments on ${effectiveHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, + "comments", ); toast.success(`Asked ${agentName} to address the review comments`); }, [agentName, chatId, effectiveHref, onAutomationPrompt, openThreads]); diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 6c7daa537..b1efedbb2 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -246,6 +246,36 @@ --buzz-shimmer-highlight: color-mix(in oklch, hsl(var(--primary)) 60%, black); } +/* Glyph recoloring alone is too subtle at sidebar sizes — sweep a + translucent highlight WASH behind the text as well (same keyframes, so + band and wash move together). The wash is a soft primary tint the text + stays readable through, like a highlighter pass. */ +.buzz-shimmer-accent::after { + animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite; + background-image: linear-gradient( + 90deg, + transparent 0%, + transparent 40%, + hsl(var(--primary) / 0.28) 50%, + transparent 60%, + transparent 100% + ); + background-repeat: no-repeat; + background-size: var(--buzz-shimmer-band) 100%; + border-radius: 0.25rem; + content: ""; + inset: -0.125rem -0.25rem; + pointer-events: none; + position: absolute; +} + +@media (prefers-reduced-motion: reduce) { + .buzz-shimmer-accent::after { + animation: none; + content: none; + } +} + @media (prefers-reduced-motion: reduce) { .buzz-shimmer, .shimmer { diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index a45de1dfb..c44b4b2f4 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -214,9 +214,15 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { ), ) .toBe(true); + // The instruction renders as a collapsed marker row, not a bubble. + const automationRow = page.getByTestId("chat-automation-row"); + await expect(automationRow).toBeVisible({ timeout: 10_000 }); + await expect(automationRow).toContainText( + "Asked Fizz to address the review comments", + ); await expect( page.getByLabel("Chat messages").getByText("unanswered review comments"), - ).toHaveCount(0); + ).not.toBeVisible(); // Manual overrides: open comments expose "Run now"; green CI does not. await expect(page.getByTestId("automation-run-comments-now")).toBeVisible();