Skip to content

Commit e27fd88

Browse files
samejrTrigger.dev RepoOps
authored andcommitted
feat(webapp): improves the Ask Trigger composer and chat header
Ask Trigger's message box is now a single rounded panel holding your message, its context, New chat and Send, with a note under it that Ask Trigger is an AI assistant and can make mistakes. The chat header has a standard layout menu, simpler history rows and delayed tooltips, the Fullscreen layout is now called Full page, and buttons that open menus across the dashboard now show a pointer cursor. Mono-RevId: cf719a8c7855f6b2d3856f6b1875fd528b2a8d56
1 parent 6516b09 commit e27fd88

28 files changed

Lines changed: 1362 additions & 691 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Ask Trigger's message box is now a single panel holding your message, its context, New chat and Send, with a note that Ask Trigger is an AI assistant and can make mistakes. The chat header has a simpler layout menu and history list, and the Fullscreen layout is now called Full page.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Paragraph } from "~/components/primitives/Paragraph";
2+
import { cn } from "~/utils/cn";
3+
import { ASK_AGENT_LABEL } from "./agent-identity";
4+
5+
// Names the agent as AI, not only fallible: "Ask Trigger" alone could read as a support team.
6+
export const AGENT_AI_DISCLOSURE = `${ASK_AGENT_LABEL} is an AI assistant and can make mistakes.`;
7+
8+
/** The panel's last line in every state (see `AgentPanelColumn`), so it always says the agent is
9+
* AI. Faded 30% toward the background, quieter than anything the user acts on. */
10+
export function AgentAiDisclosure({ className }: { className?: string }) {
11+
return (
12+
<Paragraph
13+
variant="extra-small"
14+
className={cn("text-balance text-center opacity-70", className)}
15+
>
16+
{AGENT_AI_DISCLOSURE}
17+
</Paragraph>
18+
);
19+
}

‎apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,26 @@ import { ASK_AGENT_LABEL } from "./agent-identity";
88
import { messageQuotaReachedCopy } from "./message-quota";
99

1010
// Matches the composer's outer geometry so the replacement lands in the same place.
11-
const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
11+
const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pt-1";
1212

13-
export function AgentUpgradeBlock({
14-
limit,
15-
planResolved,
16-
context,
17-
}: {
13+
type AgentUpgradeBlockProps = {
1814
limit: number;
1915
planResolved: boolean;
2016
context?: React.ReactNode;
21-
}) {
17+
};
18+
19+
export function AgentUpgradeBlock(props: AgentUpgradeBlockProps) {
2220
const organization = useOrganization();
21+
return <AgentUpgradeBlockView {...props} upgradePath={v3BillingPath(organization)} />;
22+
}
2323

24+
/** The block without the organization lookup, so it renders outside an org route. */
25+
export function AgentUpgradeBlockView({
26+
limit,
27+
planResolved,
28+
context,
29+
upgradePath,
30+
}: AgentUpgradeBlockProps & { upgradePath: string }) {
2431
return (
2532
<div className={SLOT}>
2633
{context}
@@ -32,24 +39,34 @@ export function AgentUpgradeBlock({
3239
</span>
3340
</div>
3441
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit, planResolved)}</p>
35-
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
42+
<LinkButton variant="primary/small" to={upgradePath} fullWidth>
3643
Upgrade
3744
</LinkButton>
3845
</div>
3946
</div>
4047
);
4148
}
4249

43-
export function AgentQuotaNotice({ remaining, limit }: { remaining: number; limit: number }) {
50+
type AgentQuotaNoticeProps = { remaining: number; limit: number };
51+
52+
export function AgentQuotaNotice(props: AgentQuotaNoticeProps) {
4453
const organization = useOrganization();
54+
return <AgentQuotaNoticeView {...props} upgradePath={v3BillingPath(organization)} />;
55+
}
4556

57+
/** The notice without the organization lookup, so it renders outside an org route. */
58+
export function AgentQuotaNoticeView({
59+
remaining,
60+
limit,
61+
upgradePath,
62+
}: AgentQuotaNoticeProps & { upgradePath: string }) {
4663
return (
47-
<div className="flex shrink-0 items-center gap-1 bg-background-bright px-3 pb-2 text-xs text-text-dimmed">
64+
<div className="flex items-center gap-1 text-xs text-text-dimmed">
4865
<span>
4966
{remaining} of {limit} free messages left
5067
</span>
5168
<span aria-hidden>·</span>
52-
<Link to={v3BillingPath(organization)} className={textLinkClassName()}>
69+
<Link to={upgradePath} className={textLinkClassName()}>
5370
Upgrade
5471
</Link>
5572
</div>

‎apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx‎

Lines changed: 16 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,11 @@ import {
1111
import { useLocation, useNavigate } from "@remix-run/react";
1212
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
1313
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
14-
import { PlusIcon } from "~/assets/icons/PlusIcon";
15-
import { Button } from "~/components/primitives/Buttons";
16-
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
1714
import { useToast } from "~/components/primitives/Toast";
1815
import { AgentQuotaNotice, AgentUpgradeBlock } from "./AgentUpgradeGate";
19-
import { DashboardAgentComposer } from "./DashboardAgentComposer";
16+
import { DashboardAgentComposer, NewChatButton } from "./DashboardAgentComposer";
2017
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
2118
import { DashboardAgentHero } from "./DashboardAgentHero";
22-
import { NEW_CHAT_SHORTCUT } from "./DashboardAgentHeader";
2319
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
2420
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
2521
import {
@@ -646,37 +642,21 @@ export function DashboardAgentChat({
646642
context={contextBanner}
647643
/>
648644
) : (
649-
<>
650-
<DashboardAgentComposer
651-
value={input}
652-
onChange={setInput}
653-
onSubmit={() => submit(input)}
654-
onStop={stop}
655-
isStreaming={isStreaming}
656-
focusKey={sendRequest?.seq}
657-
context={contextBanner}
658-
trailingAction={
659-
showNewChat && (
660-
<Button
661-
variant="minimal/small"
662-
className="aspect-square h-6 shrink-0 p-1 mr-[6px]"
663-
aria-label="New chat"
664-
tooltip={
665-
<span className="flex items-center">
666-
New chat
667-
<ShortcutKey shortcut={NEW_CHAT_SHORTCUT} variant="medium" />
668-
</span>
669-
}
670-
onClick={onNewChat}
671-
LeadingIcon={<PlusIcon className="size-4 text-text-dimmed" />}
672-
/>
673-
)
674-
}
675-
/>
676-
{quota.kind === "within" && (
677-
<AgentQuotaNotice remaining={quota.remaining} limit={quota.limit} />
678-
)}
679-
</>
645+
<DashboardAgentComposer
646+
value={input}
647+
onChange={setInput}
648+
onSubmit={() => submit(input)}
649+
onStop={stop}
650+
isStreaming={isStreaming}
651+
focusKey={sendRequest?.seq}
652+
context={contextBanner}
653+
trailingAction={showNewChat && <NewChatButton onClick={onNewChat} />}
654+
notice={
655+
quota.kind === "within" ? (
656+
<AgentQuotaNotice remaining={quota.remaining} limit={quota.limit} />
657+
) : undefined
658+
}
659+
/>
680660
)}
681661
</>
682662
);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { createElement, type ReactElement } from "react";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { describe, expect, it } from "vitest";
4+
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
5+
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
6+
import { AGENT_AI_DISCLOSURE } from "./AgentAiDisclosure";
7+
import {
8+
DashboardAgentComposer,
9+
type DashboardAgentComposerLayout,
10+
} from "./DashboardAgentComposer";
11+
import { AgentPanelColumn } from "./panel-layout";
12+
13+
const CONTEXT = "context-slot";
14+
const TRAILING_ACTION = "trailing-action-slot";
15+
const NOTICE = "notice-slot";
16+
17+
function render(element: ReactElement) {
18+
return renderToStaticMarkup(
19+
createElement(
20+
OperatingSystemContextProvider,
21+
{ platform: "mac" },
22+
createElement(ShortcutsProvider, null, element)
23+
)
24+
);
25+
}
26+
27+
function composer(layout: DashboardAgentComposerLayout, isStreaming = false) {
28+
return createElement(DashboardAgentComposer, {
29+
layout,
30+
value: "Why did this run fail?",
31+
onChange: () => {},
32+
onSubmit: () => {},
33+
onStop: () => {},
34+
isStreaming,
35+
context: createElement("span", null, CONTEXT),
36+
trailingAction: createElement("span", null, TRAILING_ACTION),
37+
notice: createElement("span", null, NOTICE),
38+
});
39+
}
40+
41+
function occurrences(html: string, needle: string) {
42+
return html.split(needle).length - 1;
43+
}
44+
45+
/**
46+
* Markup order stands in for the layout: the panel is everything from the text box up to the
47+
* notice, which sits under it. The webapp renders on the server, so this needs no DOM.
48+
*/
49+
describe.each(["docked", "hero"] as const)("the %s composer", (layout) => {
50+
it("leaves the AI disclosure to the panel column around it", () => {
51+
expect(render(composer(layout))).not.toContain(AGENT_AI_DISCLOSURE);
52+
});
53+
54+
it("keeps the context, the trailing action and Send inside the panel, once each", () => {
55+
const html = render(composer(layout));
56+
const textBox = html.indexOf("<textarea");
57+
const notice = html.indexOf(NOTICE);
58+
for (const needle of [CONTEXT, TRAILING_ACTION, 'aria-label="Send"']) {
59+
expect(occurrences(html, needle), needle).toBe(1);
60+
expect(html.indexOf(needle), needle).toBeGreaterThan(textBox);
61+
expect(html.indexOf(needle), needle).toBeLessThan(notice);
62+
}
63+
});
64+
65+
it("puts Stop where Send was while a turn is in flight", () => {
66+
const html = render(composer(layout, true));
67+
expect(html).not.toContain('aria-label="Send"');
68+
const stop = html.indexOf('aria-label="Stop generating"');
69+
expect(stop).toBeGreaterThan(html.indexOf(TRAILING_ACTION));
70+
expect(stop).toBeLessThan(html.indexOf(NOTICE));
71+
});
72+
});
73+
74+
describe("the panel column", () => {
75+
it("ends with the AI disclosure, once, under the composer and its notice", () => {
76+
const html = render(createElement(AgentPanelColumn, { fullscreen: false }, composer("docked")));
77+
expect(occurrences(html, AGENT_AI_DISCLOSURE)).toBe(1);
78+
expect(html.indexOf(AGENT_AI_DISCLOSURE)).toBeGreaterThan(html.indexOf(NOTICE));
79+
});
80+
81+
it("ends with it whatever fills the panel, so it stays put from new chat to chat", () => {
82+
const html = render(
83+
createElement(AgentPanelColumn, { fullscreen: true }, createElement("div", null, "hero-slot"))
84+
);
85+
expect(occurrences(html, AGENT_AI_DISCLOSURE)).toBe(1);
86+
expect(html.indexOf(AGENT_AI_DISCLOSURE)).toBeGreaterThan(html.indexOf("hero-slot"));
87+
});
88+
});

0 commit comments

Comments
 (0)