Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 40 additions & 0 deletions app/src/activity.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* What a bot is doing right now (features/chat/ToolGroup.tsx): a light
sweeps across the name of the call it is on. */

.activity-shine {
background:
linear-gradient(
90deg,
var(--muted) 0%,
var(--muted) 35%,
var(--ink) 50%,
var(--muted) 65%,
var(--muted) 100%
)
0 0 / 250% 100%;
background-clip: text;
color: transparent;
animation: activity-shine 2.2s linear infinite;
}

@keyframes activity-shine {
from {
background-position: 100% 0;
}
to {
background-position: 0% 0;
}
}

/* Without motion, the name stands still in plain ink. */
@media (prefers-reduced-motion: reduce) {
.activity-shine {
background: none;
color: var(--ink);
}
}

:root[data-motion="less"] .activity-shine {
background: none;
color: var(--ink);
}
3 changes: 3 additions & 0 deletions app/src/features/browser/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ describe("browser panel", () => {
});
fake.chat.tool(scout.id, "Bash", { summary: "ls", status: "done" });
await openScout(fake);
fireEvent.click(
screen.getByRole("button", { name: "Took 1 step in the browser, ran 1 command" }),
);
expect(screen.getAllByRole("button", { name: /Watch in browser/ })).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Watch in browser: Open a page" }));
expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined();
Expand Down
14 changes: 10 additions & 4 deletions app/src/features/chat/BotRun.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Markdown } from "./Markdown";
import { runParts, splitDraft } from "./rows";
import { SharedFiles } from "./SharedFiles";
import { isSharedFiles } from "./shared";
import { ToolLines } from "./ToolLines";
import { ToolGroup } from "./ToolGroup";

export interface Live {
/** Text of the reply being written. */
Expand Down Expand Up @@ -108,6 +108,12 @@ function BotRunView({
live?: Live | undefined;
}) {
const first = items[0];
const parts = runParts(items);
// The bot is still on the last group of calls until it writes or stops.
const busy = Boolean(live?.working && !live.draft);
const tail = parts.at(-1)?.[0];
// Its line already says it is working.
const onCalls = tail?.body.kind === "tool" && !isSharedFiles(tail);
return (
// A turn shows up as the bot starts it, still empty; what it does then
// arrives inside, so a run with items never animates as a whole.
Expand All @@ -123,14 +129,14 @@ function BotRunView({
</time>
)}
</div>
{runParts(items).map((part) => {
{parts.map((part, index) => {
const head = part[0] as ChatItem;
switch (head.body.kind) {
case "tool":
return isSharedFiles(head) ? (
<SharedFiles key={head.id} item={head} botId={bot.id} bot={bot.name} />
) : (
<ToolLines key={head.id} items={part} />
<ToolGroup key={head.id} items={part} active={busy && index === parts.length - 1} />
);
case "reply":
// It was already on screen as it was written.
Expand Down Expand Up @@ -158,7 +164,7 @@ function BotRunView({
}
})}
{live?.draft && <Draft text={live.draft} />}
{live && !live.draft && live.working && <Working />}
{busy && !onCalls && <Working />}
</div>
</li>
);
Expand Down
69 changes: 50 additions & 19 deletions app/src/features/chat/InboundRow.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// A message to the bot, in its chat: the owner's on the right in a bubble,
// another bot's or Botloft's on the left under its name (spec 15.3).

import { AlarmClock, ListTodo, Reply } from "lucide-react";
import { memo } from "react";
import { AlarmClock, ChevronRight, ListTodo, Reply } from "lucide-react";
import { memo, useState } from "react";
import { useT } from "../../i18n";
import { when } from "../../lib/format";
import type { Bot, Message } from "../../lib/protocol.gen";
Expand All @@ -11,6 +11,7 @@ import { useArrival } from "../../ui/motion";
import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar";
import { DeliveryStatus } from "../messages/DeliveryStatus";
import { AttachmentList } from "./AttachmentList";
import { Mentions } from "./mentions";

function Time({ at }: { at: number }) {
return (
Expand All @@ -31,7 +32,7 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) {
className="max-w-[36rem] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-sunken px-4 py-2.5 leading-relaxed"
data-selectable
>
{message.body}
<Mentions text={message.body} />
</p>
)}
<div className="flex items-center gap-2">
Expand All @@ -42,19 +43,41 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) {
);
}

function TaskTag({ message }: { message: Message }) {
/** A routine's tag, which opens what the routine asks: folded, it is only the name. */
function RoutineTag({
message,
open,
onToggle,
}: {
message: Message;
open: boolean;
onToggle: () => void;
}) {
const t = useT();
const routine = useApp((state) =>
message.routineId ? state.routines[message.routineId] : undefined,
);
if (message.kind === "routine") {
return (
<span className="inline-flex items-center gap-1 rounded-lg border border-accent/40 px-1.5 text-accent-text text-xs">
<AlarmClock aria-hidden size={11} />
{t.routines.tag(routine?.name ?? t.routines.tab)}
</span>
);
}
return (
<button
type="button"
aria-expanded={open}
title={t.chat.inbound.routine}
onClick={onToggle}
className="inline-flex items-center gap-1 rounded-lg border border-accent/40 px-1.5 text-accent-text text-xs transition-colors hover:bg-accent/10"
>
<AlarmClock aria-hidden size={11} />
{t.routines.tag(routine?.name ?? t.routines.tab)}
<ChevronRight
aria-hidden
size={11}
className={`transition-transform ${open ? "rotate-90" : ""}`}
/>
</button>
);
}

function TaskTag({ message }: { message: Message }) {
const t = useT();
if (message.kind !== "task" && message.kind !== "result") {
return null;
}
Expand All @@ -74,6 +97,8 @@ function OtherMessage({ message }: { message: Message }) {
const system = message.fromKind === "system";
const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.goneBot);
const arrival = useArrival(message.createdAt);
const routine = message.kind === "routine";
const [open, setOpen] = useState(false);
return (
<li className={`flex gap-3 pr-12 ${arrival}`}>
<BotAvatar
Expand All @@ -85,15 +110,21 @@ function OtherMessage({ message }: { message: Message }) {
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-semibold">{name}</span>
{sender && <span className="font-mono text-muted text-xs">@{sender.handle}</span>}
<TaskTag message={message} />
{routine ? (
<RoutineTag message={message} open={open} onToggle={() => setOpen(!open)} />
) : (
<TaskTag message={message} />
)}
<Time at={message.createdAt} />
</div>
<p
className={`mt-1 max-w-[36rem] whitespace-pre-wrap break-words rounded-2xl rounded-tl-md border border-line bg-panel px-4 py-2.5 leading-relaxed ${system ? "text-ink-soft" : ""}`}
data-selectable
>
{message.body}
</p>
{(!routine || open) && (
<p
className={`mt-1 max-w-[36rem] ${routine ? "animate-rise" : ""} whitespace-pre-wrap break-words rounded-2xl rounded-tl-md border border-line bg-panel px-4 py-2.5 leading-relaxed ${system ? "text-ink-soft" : ""}`}
data-selectable
>
<Mentions text={message.body} />
</p>
)}
{delivery && delivery.state !== "sent" && (
<div className="mt-1">
<DeliveryStatus delivery={delivery} />
Expand Down
5 changes: 4 additions & 1 deletion app/src/features/chat/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import remarkGfm from "remark-gfm";
import { useT } from "../../i18n";
import { useHost } from "../../store/context";
import { attempt } from "../../ui/toast";
import { remarkMentions } from "./mentions";

function isWeb(href: string | undefined): href is string {
return href !== undefined && /^https?:\/\/\S+$/i.test(href);
Expand Down Expand Up @@ -55,6 +56,8 @@ function Picture({ src, alt }: { src: string | Blob | undefined; alt: string | u
);
}

const plugins = [remarkGfm, remarkMentions];

const components: Components = {
a: ({ href, children }) => <Link href={href}>{children}</Link>,
img: ({ src, alt }) => <Picture src={src} alt={alt} />,
Expand All @@ -70,7 +73,7 @@ export const Markdown = memo(function Markdown({
}) {
return (
<div className={`chat-md ${streaming ? "is-streaming" : ""}`} data-selectable>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
<ReactMarkdown remarkPlugins={plugins} components={components}>
{text}
</ReactMarkdown>
</div>
Expand Down
80 changes: 80 additions & 0 deletions app/src/features/chat/ToolGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Tool calls that ran together, folded into one line (spec 15.3). While
// the bot works, the line says what it is doing now and changes with each
// call; once it is done, the line sums the group up. The arrow opens every
// call, each with its own line.

import { ChevronRight, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import type { ChatItem, ToolItem } from "../../lib/protocol.gen";
import { ToolLines } from "./ToolLines";
import { toolDetail, toolTitle } from "./toolNames";
import { toolSummary } from "./toolSummary";

/** The call the bot is on, swapped in as the next one starts. */
function Now({ tool }: { tool: ToolItem }) {
const t = useT();
const detail = tool.explanation || toolDetail(tool, t.tools);
return (
<span className="flex min-w-0 animate-rise items-center gap-2">
<LoaderCircle
aria-label={t.chat.tools.running}
size={14}
className="shrink-0 animate-spin text-work"
/>
<span className="activity-shine shrink-0 font-medium">{toolTitle(tool.name, t.tools)}</span>
{detail && (
<span
className={`min-w-0 truncate text-muted text-xs ${tool.explanation ? "" : "font-mono"}`}
title={detail}
>
{detail}
</span>
)}
</span>
);
}

export function ToolGroup({ items, active }: { items: ChatItem[]; active: boolean }) {
const t = useT();
const [open, setOpen] = useState(false);
const calls = items.flatMap((item) =>
item.body.kind === "tool" ? [{ id: item.id, tool: item.body }] : [],
);
const now = calls.at(-1);
// One finished call is already a single line.
if (!now || (!active && calls.length === 1)) {
return <ToolLines items={items} />;
}
return (
<div className="-mx-1.5 flex flex-col">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="flex min-w-0 max-w-full items-center gap-2 self-start rounded-lg px-1.5 py-1 text-left text-muted text-sm transition-colors hover:bg-sunken hover:text-ink"
>
{active ? (
<Now key={now.id} tool={now.tool} />
) : (
<span className="min-w-0 truncate">
{toolSummary(
calls.map((call) => call.tool),
t.chat.tools.group,
)}
</span>
)}
<ChevronRight
aria-hidden
size={13}
className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
/>
</button>
{open && (
<div className="ml-3 animate-rise border-line border-l pl-3">
<ToolLines items={items} />
</div>
)}
</div>
);
}
6 changes: 4 additions & 2 deletions app/src/features/chat/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ describe("chat", () => {
fake.chat.turn(scout.id);
fake.setBotState(scout.id, "idle");
});
expect(within(line).getByLabelText("Done")).toBeDefined();
fireEvent.click(line);
// Done, the call is a line of its own again.
const ran = within(chat()).getByRole("button", { name: /Run a command/ });
expect(within(ran).getByLabelText("Done")).toBeDefined();
fireEvent.click(ran);
expect(within(chat()).getByText("12 passed")).toBeDefined();
expect(within(chat()).getByText("Command")).toBeDefined();
expect(within(chat()).getByText("npm test")).toBeDefined();
Expand Down
41 changes: 41 additions & 0 deletions app/src/features/chat/mentions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeHost } from "../../lib/fakeHost";
import { HostProvider } from "../../store/context";
import { Markdown } from "./Markdown";
import { splitMentions } from "./mentions";

afterEach(cleanup);

const mentions = (text: string) =>
splitMentions(text)
.filter((part) => part.mention)
.map((part) => part.text);

describe("mentions", () => {
test("are handles on their own, not e-mails or paths", () => {
expect(mentions("@manychat confirmed, ask @atendente-2.")).toEqual([
"@manychat",
"@atendente-2",
]);
expect(mentions("(@writer) and @Writer")).toEqual(["@writer", "@Writer"]);
expect(mentions("mail ana@example.com or see C:\\x\\@cache and node_modules/@types")).toEqual(
[],
);
expect(splitMentions("hi @scout!").map((part) => part.text)).toEqual(["hi ", "@scout", "!"]);
});

test("stand out in a reply, but not inside code or links", () => {
const { container } = render(
<HostProvider host={new FakeHost()}>
<Markdown
text={
"O **@manychat** confirmou. Rode `npm i @types/x` e veja [@docs](https://example.com)."
}
/>
</HostProvider>,
);
const marked = [...container.querySelectorAll(".chat-mention")].map((node) => node.textContent);
expect(marked).toEqual(["@manychat"]);
});
});
Loading
Loading