Make Run now trustworthy and the sidebar shimmer unmissable

Run now: the toast only proved the click happened — the send's failure
was swallowed, the prompt was fully invisible, and a stopped default
agent silently ate it. Automation prompts now surface send errors,
auto-start a stopped default agent (the backlog replay delivers the
prompt once it connects), and leave a collapsed marker row at the spot
the instruction fired — "Asked Fizz to fix the CI failures" —
expandable to the full text, so the magic has an anchor.

Shimmer: glyph recoloring alone is too subtle at sidebar sizes. The
accent shimmer adds a translucent primary highlight wash sweeping
behind the text on the same keyframes — a highlighter pass the text
stays readable through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-07 07:52:30 +01:00
co-authored by Claude Fable 5
parent 09b61fbc85
commit 73340ac6ce
6 changed files with 127 additions and 8 deletions
@@ -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;
@@ -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 (
<Message side="center">
<MessageContent className="w-full max-w-full">
<details
className="group/automation-row"
data-testid="chat-automation-row"
>
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm text-muted-foreground/70 [&::-webkit-details-marker]:hidden">
<Wand2 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">
{chatAutomationLabel(tag, agentName)}
</span>
</summary>
<div className="mt-2 pl-6 text-sm text-muted-foreground">
{event.content}
</div>
</details>
</MessageContent>
</Message>
);
}
export function ChatContextRow({ event }: { event: RelayEvent }) {
const isProjectSetup = event.content.startsWith("Project setup");
const projectSetupContent = event.content
+30 -5
View File
@@ -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 ? (
<ChatAutomationRow
agentName={defaultAgent?.name ?? "Fizz"}
event={message}
/>
) : isContextMessage ? (
<ChatContextRow event={message} />
) : (
<ChatMessageRow
@@ -738,9 +765,7 @@ export function ChatDetail({
branch={workBranch}
chatId={chat.id}
isTurnActive={isChatTurnActive}
onAutomationPrompt={(content) =>
void onSend(content, [], [CHAT_AUTOMATION_TAG])
}
onAutomationPrompt={handleAutomationPrompt}
open={showWorkPanel}
prHref={workPanelHref}
projectPath={metadata?.projectPath ?? selectedProject?.path ?? null}
@@ -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]);
@@ -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 {
@@ -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();