Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@shadcn/react": "^0.3.0",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-form": "^1.33.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.27",
"better-auth": "^1.6.27",
Expand All @@ -31,6 +32,7 @@
"prompt-area": "^0.6.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-use-measure": "^2.1.7",
"shadcn": "^4.17.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
Expand Down
1 change: 1 addition & 0 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ function ChannelRow({
}
pinned={channel.pinned}
unread={unread}
busy={channel.busy ?? false}
/>
</motion.div>
);
Expand Down
8 changes: 7 additions & 1 deletion app/src/components/app-sidebar/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const Channel = memo(function Channel({
lastMessageAt,
pinned,
unread,
busy,
}: {
channelId: string;
participantIds: string[];
Expand All @@ -51,6 +52,7 @@ export const Channel = memo(function Channel({
lastMessageAt?: string;
pinned: boolean;
unread: boolean;
busy: boolean;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
Expand Down Expand Up @@ -110,7 +112,11 @@ export const Channel = memo(function Channel({
}}
>
<div className="">
<ChannelAvatar participantIds={participantIds} size={32} />
<ChannelAvatar
participantIds={participantIds}
size={32}
typing={busy}
/>
</div>
<div className="flex-col min-w-0 flex-1">
<div className="flex flex-row items-center justify-between gap-2">
Expand Down
71 changes: 48 additions & 23 deletions app/src/components/channels/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,81 @@
import Avatar from "boring-avatars";
import { memo } from "react";
import { cn } from "@/lib/utils";

/**
* Memoized roster avatar. Row updates usually change preview/timestamp only, and
* `use-channel-events` preserves participant id arrays for unchanged rows.
*
* `size-full` opts the generated SVG out of ancestor icon selectors such as
* `[&_svg:not([class*='size-'])]:size-4`.
*
* `typing` overlays a working indicator at the bottom-right — three bouncing dots, so a channel
* whose agent is mid-turn reads as busy from the roster without moving the row's layout.
*/
export const ChannelAvatar = memo(function ChannelAvatar({
participantIds,
size = 32,
typing = false,
}: {
participantIds: string[];
size?: number;
typing?: boolean;
}) {
const channelSize = participantIds?.length;

if (channelSize === 1) {
return (
<div className="" style={{ height: size, width: size }}>
<Avatar className="size-full" name={participantIds[0]} size={size} />
</div>
);
}

const firstThree = participantIds.slice(0, 3);

return (
<div
className="flex flex-row items-center"
style={{ height: size, width: size }}
>
{firstThree.map((c, i) => {
return (
const avatar =
channelSize === 1 ? (
<Avatar className="size-full" name={participantIds[0]} size={size} />
) : (
<div className="flex flex-row items-center size-full">
{participantIds.slice(0, 3).map((c, i, shown) => (
<div
key={c}
className="shrink-0 border-2 border-sidebar rounded-full flex items-center justify-center"
key={c}
style={{
height: size / (firstThree.length / 2),
width: size / (firstThree.length / 2),
height: size / (shown.length / 2),
width: size / (shown.length / 2),
transform: `translateX(${i * -75}%)`,
}}
>
<Avatar
className="size-full"
name={c}
size={size / (firstThree.length / 2)}
size={size / (shown.length / 2)}
/>
</div>
);
})}
))}
</div>
);

return (
<div className="relative" style={{ height: size, width: size }}>
{avatar}
{typing ? <TypingBadge /> : null}
</div>
);
});

/**
* Three bouncing dots in a small badge, ringed in the sidebar's own colour so it sits on the
* avatar as a badge rather than floating over it. The staggered negative delays start each dot at
* a different point in the same bounce, which is what makes the three read as one wave.
*/
function TypingBadge() {
return (
<div className="absolute -bottom-0.5 -right-0.5 flex items-center gap-0.5 rounded-full bg-sidebar p-0.5 ring-2 ring-sidebar">
<span className="sr-only">Working…</span>
<Dot className="[animation-delay:-0.3s]" />
<Dot className="[animation-delay:-0.15s]" />
<Dot />
</div>
);
}

function Dot({ className }: { className?: string }) {
return (
<span
className={cn("size-1 rounded-full bg-primary animate-bounce", className)}
/>
);
}
97 changes: 95 additions & 2 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@ import {
transcriptMessages,
} from "@/components/channels/transcript-messages";
import { agentListQueryOptions } from "@/lib/agents/queries";
import { recordChannelActivityMutationOptions } from "@/lib/channels/mutations";
import type { AgentChannel } from "@/lib/channels/queries";
import {
recordChannelActivityMutationOptions,
setChannelBusyMutationOptions,
} from "@/lib/channels/mutations";
import {
type AgentChannel,
type ChannelSummary,
channelKeys,
} from "@/lib/channels/queries";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { ConversationProvider } from "@/lib/copilot/conversation";
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
import { stoppedReason } from "@/lib/copilot/stopped-turn";
import { readThreadMessages } from "@/lib/copilot/thread-messages";
import { useSkillCommands } from "@/lib/plugins/skill-commands";
import { queryClient } from "@/query-client";
import { newId } from "../../lib/new-id";

/**
Expand Down Expand Up @@ -182,6 +190,73 @@ export function ChannelChat({
};
}, [copilotkit, agent, isReady, channel.threadId, runtimeAgentId]);

/*
* A turn nobody here streamed, surfaced while the channel is open.
*
* A relayed handoff answer runs on the server and lands in this thread with no browser attached.
* The roster hears about it — the activity socket patches the channel-list cache — but this
* transcript restores history once, on mount, and would show the new turn only after leaving and
* coming back. So it watches that same cache: when this channel's `lastMessageAt` advances to a
* moment a Bot authored, the durable history is read again. Riding the roster's own cache rather
* than a second subscription means "the sidebar updated" and "the transcript refreshes" are the
* one signal, and cannot drift apart.
*
* APPENDED BY ID, NOT COMPARED BY LENGTH. The stored history is not the local transcript: it
* keeps only what `readableTurns` can parse, and the local side keeps tool lines the platform
* does not hand back — so after a headless turn the stored read can be shorter than the screen
* and still hold the news. What is new is exactly the messages whose ids this transcript has
* never seen; appending them leaves everything local intact, and this tab's own turns echo back
* with ids already on screen and append nothing.
*
* Retried briefly, because the roster is patched when the turn is on record with the runner and
* the platform's read of the thread can be a beat behind it.
*/
useEffect(() => {
const authoredAt = () => {
const cache = queryClient.getQueryData<{
pages: { channels: ChannelSummary[] }[];
}>(channelKeys.list());
const summary = cache?.pages
.flatMap((page) => page.channels)
.find((row) => row.id === channel.id);
// Only a Bot's turn is news here; a person's own line arrives through the run that sent it.
if (!summary || summary.lastMessageAgentId === null) return null;
return summary.lastMessageAt;
};

let lastSeen = authoredAt();

const pull = () => {
void (async () => {
for (const delayMs of [0, 750, 1500]) {
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
const stored = await readThreadMessages(
channel.threadId,
runtimeAgentId,
);
const current = agentRef.current;
const seen = new Set(current.messages.map((message) => message.id));
const fresh = stored.messages.filter(
(message) => !seen.has(message.id),
);
if (fresh.length === 0) continue;
current.setMessages([...current.messages, ...fresh]);
return;
}
})();
};

return queryClient.getQueryCache().subscribe(() => {
const at = authoredAt();
if (at && at !== lastSeen) {
lastSeen = at;
pull();
}
});
}, [channel.id, channel.threadId, runtimeAgentId]);

// Tool calls from this conversation act on this coworker's own computer.
useActiveBot(runtimeAgentId);

Expand Down Expand Up @@ -222,6 +297,24 @@ export function ChannelChat({
* Tell the roster what was just said. Failures here must not block the conversation.
*/
const recordActivity = useMutation(recordChannelActivityMutationOptions());

/*
* Show this channel as working on the roster while its own turn runs.
*
* The server cannot see a person's turn begin — the runtime does not tell it — so the browser
* reports it, keyed on whether a turn is in flight. The server broadcasts it to every member, so
* the row shows the dots even on a tab that has since navigated elsewhere; a run that outlives
* this tab clears itself when the roster next refetches, which is the acceptable failure for a
* transient hint. Not cleared on unmount on purpose: a turn keeps running server-side after the
* person leaves the channel, and clearing here would drop the indicator while the work goes on.
*/
const setBusy = useMutation(setChannelBusyMutationOptions());
const busy = turnsInFlight > 0;
// Keyed on the busy transition alone; `setBusy.mutate` is a stable handle, not a dependency.
// biome-ignore lint/correctness/useExhaustiveDependencies: firing on the busy transition only.
useEffect(() => {
setBusy.mutate({ channelId: channel.id, busy });
}, [busy, channel.id]);
const report = (text: string, agentId: string | null) => {
const trimmed = text.trim();
if (!trimmed) return;
Expand Down
23 changes: 20 additions & 3 deletions app/src/components/channels/composer/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const COMPACT_MAX_HEIGHT_PX = 96;

export type ComposerProps = {
className?: string;
/**
* Classes for the editor itself rather than the frame. `className` styles the box — border,
* background, width; the type inside it is PromptArea's, so changing it (a hero composer's
* `text-lg`) goes through here, where tailwind-merge lets it beat the built-in `text-sm`.
*/
editorClassName?: string;
compact?: boolean;
/** Agents that `@` can address. Empty means the mention menu reports an empty channel. */
agents?: readonly AgentOption[];
Expand Down Expand Up @@ -94,10 +100,12 @@ export type ComposerProps = {
* Defaults to `pending`, which is the right answer for a caller with no gap between the two.
*/
stoppable?: boolean;
initialValue?: string;
};

export function Composer({
className,
editorClassName,
compact = false,
agents = [],
commands = PLACEHOLDER_COMMANDS,
Expand All @@ -108,8 +116,11 @@ export function Composer({
pending = false,
autoFocus = false,
stoppable,
initialValue,
}: ComposerProps) {
const [value, setValue] = useState<Segment[]>([]);
const [value, setValue] = useState<Segment[]>(
initialValue ? [{ type: "text", text: initialValue }] : [],
);
const [isSubmitting, setIsSubmitting] = useState(false);
const submitInFlight = useRef(false);
const promptAreaRef = useRef<PromptAreaHandle>(null);
Expand Down Expand Up @@ -292,7 +303,10 @@ export function Composer({
</Button>
<PromptArea
aria-label="Message"
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-sm shadow-none"
className={cn(
"min-w-0 flex-1 border-0 bg-transparent p-0 text-sm shadow-none",
editorClassName,
)}
disabled={disabled}
maxHeight={COMPACT_MAX_HEIGHT_PX}
minHeight={COMPACT_MIN_HEIGHT_PX}
Expand Down Expand Up @@ -342,7 +356,10 @@ export function Composer({
<PromptArea
aria-label="Message"
autoGrow
className="w-full border-0 bg-transparent p-0 text-sm shadow-none"
className={cn(
"w-full border-0 bg-transparent p-0 text-sm shadow-none",
editorClassName,
)}
disabled={disabled}
maxHeight={MAX_HEIGHT_PX}
onChange={handleChange}
Expand Down
Loading