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
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"motion": "^13.1.0",
"prompt-area": "^0.6.3",
"react": "^19.2.0",
Expand Down
72 changes: 68 additions & 4 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
useRenderActivityMessage,
useRenderToolCall,
} from "@copilotkit/react-core/v2";
import { IconBox } from "@tabler/icons-react";
import { IconBox, IconClock } from "@tabler/icons-react";
import { motion, useReducedMotion } from "motion/react";
import { memo, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { Streamdown } from "streamdown";
Expand All @@ -23,6 +23,7 @@ import {
useMessageScroller,
} from "@/components/ui/message-scroller";
import { Skeleton } from "@/components/ui/skeleton";
import { readFiring } from "@/lib/channels/routine-firing";
import { markdownComponents } from "@/lib/markdown";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { readToolName } from "@/lib/plugins/tool-name";
Expand Down Expand Up @@ -380,6 +381,32 @@ function Arriving({
);
}

/**
* A turn a schedule asked for, drawn as the event it is.
*
* The frame around a firing is addressed to the model — see `shared/routine-firing.ts` — and it
* reached the transcript wearing `role: "user"`, which drew it as a muted bubble on the right, in
* the exact style of something the person typed. Somebody reading back through a channel found
* three sentences of instructions to a model in their own voice, telling their Bot what it may not
* do. For a product whose whole claim is that a Bot is a coworker you can hold to account, a record
* that misattributes who said what is the one thing it cannot afford.
*
* So: start-aligned and muted, because this is not the person speaking; the clock, because that is
* what a routine already is everywhere else in the app; and the instruction ALONE, because that is
* the part a person wrote and the only part addressed to them.
*/
function RoutineFiring({ instruction }: { instruction: string }) {
return (
<div className="flex min-w-0 items-baseline gap-2 text-muted-foreground text-sm">
<IconClock aria-hidden className="size-4 shrink-0 translate-y-0.5" />
<span className="min-w-0">
<span className="font-medium">Routine ran.</span>{" "}
<span className="whitespace-pre-wrap">{instruction}</span>
</span>
</div>
);
}

/**
* One drawn message, and it is memoised on PRIMITIVES ON PURPOSE.
*
Expand All @@ -405,6 +432,23 @@ const TranscriptMessage = memo(function TranscriptMessage({
text: string;
}) {
const isUser = role === "user";
/*
* Checked before anything else a person's message gets. A firing is not a person's message: the
* chip split, the end alignment and the bubble are all wrong for it, and each one of them would
* have to learn about firings separately if this branched any later.
*/
const firing = isUser ? readFiring(text) : null;
if (firing !== null) {
return (
<MessageRow align="start">
<MessageContent>
<Arriving delay={delay}>
<RoutineFiring instruction={firing} />
</Arriving>
</MessageContent>
</MessageRow>
);
}
const align = isUser ? "end" : "start";
const invoked = isUser ? splitSkillChip(text, commandNames) : null;

Expand Down Expand Up @@ -618,6 +662,24 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) {
);
}

/**
* Whether a text item is the person actually sending something, as opposed to a routine firing that
* merely arrived wearing `role: "user"`.
*
* A FIRING IS NOT THE PERSON SPEAKING — `TranscriptMessage` already knows that and draws it as
* `RoutineFiring` rather than as their bubble, via the same `readFiring` check used here. The scroll
* machinery below was the one place left that had not caught up: it smooth-scrolled and anchored on
* `role === "user"` alone, so a routine firing while somebody was reading back through the channel
* yanked their viewport to the bottom as though they had just typed and sent something. They hadn't;
* the schedule had. Exported so this can be checked without mounting anything.
*/
export function isPersonSentMessage(
role: "user" | "assistant",
text: string,
): boolean {
return role === "user" && readFiring(text) === null;
}

const SEND_SCROLL_MS = 700;

function useSmoothSendScroll(
Expand Down Expand Up @@ -689,8 +751,10 @@ export function ChatTranscript({

const viewportRef = useRef<HTMLDivElement | null>(null);
const newestUserMessageId =
items.findLast((item) => item.kind === "text" && item.role === "user")
?.id ?? null;
items.findLast(
(item) =>
item.kind === "text" && isPersonSentMessage(item.role, item.text),
)?.id ?? null;
useSmoothSendScroll(viewportRef, newestUserMessageId);

/*
Expand Down Expand Up @@ -767,7 +831,7 @@ export function ChatTranscript({
<MessageScrollerItem
key={item.id}
messageId={item.id}
scrollAnchor={item.role === "user"}
scrollAnchor={isPersonSentMessage(item.role, item.text)}
>
<TranscriptMessage
commandNames={commandNames}
Expand Down
83 changes: 53 additions & 30 deletions app/src/components/layout/page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { Link, type LinkProps } from "@tanstack/react-router";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { useOptionalSidebar } from "../ui/sidebar";
import { SidebarToggle } from "./sidebar-toggle";
import { SidebarToggle, useSidebarToggleVisible } from "./sidebar-toggle";

/**
* The frame every configuration screen sits in.
Expand Down Expand Up @@ -58,26 +57,29 @@ export function PageShell({
label: string;
};
}) {
/*
* Whether this screen has a sidebar at all. `/assist` and `/link/slack` draw PageShell directly
* under `_authed`, which mounts no provider, so there is nothing for a toggle to act on there.
*/
const hasSidebar = useOptionalSidebar() !== null;
/*
* The bar carries the toggle and the Back link, and is drawn when it has at least one of them.
* The screens with a Back link already drew exactly this bar, so for them nothing changes; what
* changed is that a sidebar is now reason enough on its own, because the toggle has to sit at the
* pane's left edge in both states and the prose column is centred — a control inside it would be
* 400px from the edge it belongs to on a wide screen. Drawing it with neither would be a 56px
* band holding nothing, which reads as a layout bug rather than as chrome.
*
* The question is whether the toggle will DRAW, not whether a sidebar exists: it hides itself on
* a desktop-width window while the sidebar is already open, which is most of the time on these
* screens. Asking `useSidebarToggleVisible` rather than re-deriving the condition here is what
* keeps the two from drifting apart and reintroducing that empty band. It also still answers false
* on `/assist` and `/link/slack`, which draw PageShell directly under `_authed` with no sidebar
* provider at all, so there is nothing for a toggle to act on there.
*/
const bar = hasSidebar || !!backButton;
const showToggle = useSidebarToggleVisible();
const bar = showToggle || !!backButton;

return (
<>
{bar ? (
<div className="max-w-7xl w-full h-14 flex items-center gap-1 px-3">
{hasSidebar ? <SidebarToggle /> : null}
{showToggle ? <SidebarToggle /> : null}
{!!backButton && (
<Button
variant="ghost"
Expand All @@ -89,27 +91,48 @@ export function PageShell({
)}
</div>
) : null}
<div
className={cn(
"mx-auto flex w-full flex-col px-4 pb-12",
// Without a bar above it the heading keeps the full original space.
bar ? "pt-8" : "pt-12",
WIDTHS[width],
className,
)}
>
<header className="flex flex-col gap-2">
<div className="flex flex-row items-center justify-between gap-4">
<h1 className="font-bold text-2xl">{title}</h1>
{action}
</div>
{description ? (
<p className="max-w-prose text-pretty text-muted-foreground text-sm leading-relaxed">
{description}
</p>
) : null}
</header>
{children}
{/*
* The scroller, and it has to live here rather than in either shell, because the two shells
* this frame is used under disagree about who scrolls.
*
* Under `_authed` — admin, settings — the document scrolls, so a page taller than the window
* has always just worked. Under `_authed/_app` it does not: that shell is `h-svh
* overflow-hidden` on purpose ("one viewport, never scrolls: panes scroll inside it") and its
* `main` is `overflow-hidden` too, so a PageShell taller than the window was silently CLIPPED
* — 246px of Skills sat below the fold with no way to reach it, by keyboard or otherwise.
*
* `min-h-0` is the load-bearing half of `min-h-0 flex-1`: a flex item's default `min-height:
* auto` refuses to shrink below its content, so `flex-1` alone would grow this past the pane
* and clip exactly as before. Under `_authed` both are inert — the parent is not a flex
* container — and `overflow-y-auto` on an auto-height element shows no scrollbar, so those
* eighteen screens are unaffected.
*
* Separate from the centred column below so the scrollbar rides the pane's edge instead of
* appearing inside a 630px measure with content either side of it.
*/}
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className={cn(
"mx-auto flex w-full flex-col px-4 pb-12",
// Without a bar above it the heading keeps the full original space.
bar ? "pt-8" : "pt-12",
WIDTHS[width],
className,
)}
>
<header className="flex flex-col gap-2">
<div className="flex flex-row items-center justify-between gap-4">
<h1 className="font-bold text-2xl">{title}</h1>
{action}
</div>
{description ? (
<p className="max-w-prose text-pretty text-muted-foreground text-sm leading-relaxed">
{description}
</p>
) : null}
</header>
{children}
</div>
</div>
</>
);
Expand Down
55 changes: 45 additions & 10 deletions app/src/components/layout/sidebar-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,38 @@ const SHORTCUT_LABEL = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
? "⌘B"
: "Ctrl+B";

/**
* Whether {@link SidebarToggle} will actually draw anything.
*
* Exported because a caller that reserves space for this control has to agree with it about when it
* exists. `PageShell` draws a 56px bar to hold it, and deciding that on "is there a sidebar" while
* the toggle decides on something narrower leaves an empty band on every configuration screen —
* which is the layout bug that file's own comment warns against.
*/
export function useSidebarToggleVisible(): boolean {
const sidebar = useOptionalSidebar();
if (!sidebar) return false;
/*
* Below 768px the sidebar is an overlay Sheet with its own open state, and `open` describes the
* desktop pane — reading it there would answer for the wrong one.
*/
const isOpen = sidebar.isMobile ? sidebar.openMobile : sidebar.open;

/*
* Nothing to draw on a desktop-width window while the sidebar is already showing: the roster is
* on screen, so a button whose whole offer is to take it away is clutter beside the screen's own
* controls. ⌘B still collapses it for anyone who wants that.
*
* Only in THAT state, though, and the distinction is the whole point. The moment the sidebar is
* gone this is the way back, so it returns — which is what stops this from re-creating the defect
* the component was written for. Collapsing is remembered across reloads (`lib/sidebar.ts` stores
* `collapsed`), so a desktop window that hid this in both states would leave somebody who pressed
* ⌘B once with every channel behind a shortcut nobody told them about. On mobile it always draws:
* the Sheet starts closed, so hiding it there would mean the roster could never be opened at all.
*/
return sidebar.isMobile || !isOpen;
}

/**
* The control that opens and closes the shell's sidebar.
*
Expand All @@ -26,24 +58,23 @@ const SHORTCUT_LABEL = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
* the eye could not find it and the keyboard could not reach it; and under 768px, where the sidebar
* becomes a Sheet that starts closed, there was no way to open the roster at all.
*
* It is therefore drawn in the chrome each screen already has, and in BOTH states, rather than
* inside the sidebar it hides — a trigger that disappears along with the sidebar cannot bring it
* back.
* It is therefore drawn in the chrome each screen already has, rather than inside the sidebar it
* hides — a trigger that disappears along with the sidebar cannot bring it back. That constraint is
* about the state where the sidebar is GONE, and it is the one {@link useSidebarToggleVisible} keeps
* absolutely: the toggle is allowed to stand down while the sidebar is already on screen, but never
* while it is the way back.
*
* Built from `Button` rather than the primitive's `SidebarTrigger` because that component hardcodes
* its own children after the prop spread, including an `sr-only` "Toggle Sidebar" that would
* contradict the label below. The state still belongs to the primitive: `toggleSidebar` is its hook.
*/
export function SidebarToggle({ className }: { className?: string }) {
const sidebar = useOptionalSidebar();
// No sidebar in scope, so nothing to toggle and nothing to draw.
if (!sidebar) return null;
const visible = useSidebarToggleVisible();
if (!sidebar || !visible) return null;
const { isMobile, open, openMobile, toggleSidebar } = sidebar;
/*
* The label says what the click will do, not what is on screen. Below 768px the sidebar is an
* overlay Sheet with its own open state, and `open` describes the desktop pane — reading it there
* would name the wrong action.
*/

// The label says what the click will do, not what is on screen.
const label = (isMobile ? openMobile : open)
? "Hide sidebar"
: "Show sidebar";
Expand Down Expand Up @@ -79,6 +110,10 @@ export function SidebarToggle({ className }: { className?: string }) {
* same 48px band it occupies everywhere else — a control that moves between screens is a control
* somebody has to look for each time. No bottom border: a divider under an otherwise empty bar is a
* line with nothing to divide.
*
* The band keeps its height even when `SidebarToggle` draws nothing, which on a desktop window is
* most of the time. That is deliberate: reserving it means the screen beneath does not jump by 48px
* each time the sidebar is collapsed and the toggle reappears.
*/
export function SidebarToggleBar() {
return (
Expand Down
Loading