experiment: board chat split-pane fraction, markdown wrap, tooltips

ResizeObserver-driven chat/agent pane split; wrapped markdown bubbles with
horizontal scroll for pre/table; new thread/history affordances.

Made-with: Cursor
This commit is contained in:
scotttong 2026-03-22 02:37:39 -07:00
parent f312f22f27
commit b6e144a611

View file

@ -1,4 +1,11 @@
import { useEffect, useState, useRef, useCallback, useMemo } from "react"; import {
useEffect,
useLayoutEffect,
useState,
useRef,
useCallback,
useMemo,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
@ -14,7 +21,12 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ListFilter, Send } from "lucide-react"; import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { History, ListFilter, MessageSquarePlus, Send } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
/** /**
@ -25,6 +37,8 @@ import { cn } from "../lib/utils";
/** Hit zone to the right of the 1px line (line sits on chat panes right edge). */ /** Hit zone to the right of the 1px line (line sits on chat panes right edge). */
const SPLIT_DIVIDER_PX = 12; const SPLIT_DIVIDER_PX = 12;
const SPLIT_MIN_PANE_PX = 280; const SPLIT_MIN_PANE_PX = 280;
/** Chat pane share of width below the divider (agent feed gets the rest). */
const DEFAULT_CHAT_FRACTION = 2 / 3;
const AGENT_FEED_FILTER_OPTIONS = [ const AGENT_FEED_FILTER_OPTIONS = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
@ -35,11 +49,12 @@ const AGENT_FEED_FILTER_OPTIONS = [
type AgentFeedFilterValue = (typeof AGENT_FEED_FILTER_OPTIONS)[number]["value"]; type AgentFeedFilterValue = (typeof AGENT_FEED_FILTER_OPTIONS)[number]["value"];
/** One row of content; bubble scrolls horizontally so nothing wraps to a second line. */ /** Wrapped markdown in bubbles; pre/table scroll horizontally when needed. */
const BOARD_CHAT_MARKDOWN_SINGLE_LINE = const BOARD_CHAT_MARKDOWN_CLASS =
"!flex w-max max-w-none flex-nowrap items-center gap-x-2 [&>*]:!my-0 [&>*]:shrink-0 [&>*]:whitespace-nowrap [&>ul]:inline-flex [&>ul]:flex-nowrap [&>ul]:gap-x-2 [&>ul]:!m-0 [&>ul]:!py-0 [&>ul]:!pl-0 [&>ol]:inline-flex [&>ol]:flex-nowrap [&>ol]:gap-x-2 [&>ol]:!m-0 [&>ol]:!py-0 [&>ol]:!pl-0 [&_li]:whitespace-nowrap"; "max-w-full overflow-visible [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:max-w-full [&_table]:overflow-x-auto";
const boardChatBubbleShell = "min-w-0 max-w-[85%] overflow-x-auto overflow-y-hidden px-3 py-2 text-sm"; const boardChatBubbleShell =
"min-w-0 max-w-[85%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible";
export function BoardChat() { export function BoardChat() {
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
@ -51,10 +66,42 @@ export function BoardChat() {
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
const splitContainerRef = useRef<HTMLDivElement>(null); const splitContainerRef = useRef<HTMLDivElement>(null);
const [leftPaneWidth, setLeftPaneWidth] = useState(480); const [containerWidth, setContainerWidth] = useState(0);
const [chatPaneFraction, setChatPaneFraction] = useState(DEFAULT_CHAT_FRACTION);
const splitDragging = useRef(false); const splitDragging = useRef(false);
const [agentFeedFilter, setAgentFeedFilter] = useState<AgentFeedFilterValue>("all"); const [agentFeedFilter, setAgentFeedFilter] = useState<AgentFeedFilterValue>("all");
useLayoutEffect(() => {
const el = splitContainerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
setContainerWidth(el.clientWidth);
});
ro.observe(el);
setContainerWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
const innerWidth = Math.max(0, containerWidth - SPLIT_DIVIDER_PX);
const splitLowerPx = SPLIT_MIN_PANE_PX;
const splitUpperPx = innerWidth - SPLIT_MIN_PANE_PX;
const minChatFraction =
innerWidth > 0 ? Math.min(1, SPLIT_MIN_PANE_PX / innerWidth) : 0;
const maxChatFraction =
innerWidth > 0 ? Math.max(0, 1 - SPLIT_MIN_PANE_PX / innerWidth) : 1;
const leftPaneWidth =
innerWidth > 0
? splitUpperPx < splitLowerPx
? Math.max(0, Math.round(innerWidth / 2))
: Math.round(
innerWidth *
Math.min(
maxChatFraction,
Math.max(minChatFraction, chatPaneFraction),
),
)
: 0;
const handleSplitDragStart = useCallback( const handleSplitDragStart = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -64,15 +111,17 @@ export function BoardChat() {
const onMouseMove = (ev: MouseEvent) => { const onMouseMove = (ev: MouseEvent) => {
if (!splitDragging.current) return; if (!splitDragging.current) return;
const containerW = splitContainerRef.current?.clientWidth ?? startWidth + 400; const containerW = splitContainerRef.current?.clientWidth ?? containerWidth;
const inner = containerW - SPLIT_DIVIDER_PX; const inner = containerW - SPLIT_DIVIDER_PX;
const lower = SPLIT_MIN_PANE_PX; const lower = SPLIT_MIN_PANE_PX;
const upper = inner - SPLIT_MIN_PANE_PX; const upper = inner - SPLIT_MIN_PANE_PX;
const next = startWidth + ev.clientX - startX; const next = startWidth + ev.clientX - startX;
if (inner <= 0) return;
if (upper < lower) { if (upper < lower) {
setLeftPaneWidth(Math.max(0, Math.round(inner / 2))); setChatPaneFraction(0.5);
} else { } else {
setLeftPaneWidth(Math.min(upper, Math.max(lower, next))); const clamped = Math.min(upper, Math.max(lower, next));
setChatPaneFraction(clamped / inner);
} }
}; };
@ -85,7 +134,7 @@ export function BoardChat() {
document.addEventListener("mousemove", onMouseMove); document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp); document.addEventListener("mouseup", onMouseUp);
}, },
[leftPaneWidth], [containerWidth, leftPaneWidth],
); );
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@ -315,22 +364,57 @@ export function BoardChat() {
ref={splitContainerRef} ref={splitContainerRef}
className="flex min-h-0 min-w-0 flex-1 flex-row" className="flex min-h-0 min-w-0 flex-1 flex-row"
> >
{/* Left: chat (self-contained pane) */} {/* Left: chat (self-contained pane) — 2/3 default until container is measured for drag math */}
<div <div
className="flex min-h-0 min-w-0 shrink-0 flex-col bg-background" className={cn(
style={{ width: leftPaneWidth }} "flex min-h-0 min-w-0 shrink-0 flex-col bg-background",
innerWidth <= 0 && "w-2/3",
)}
style={innerWidth > 0 ? { width: leftPaneWidth } : undefined}
> >
<div className="relative shrink-0 px-4 py-3"> <div className="relative flex shrink-0 items-center justify-between gap-2 px-4 py-3">
<div <div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border" className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
aria-hidden aria-hidden
/> />
<h3 className="text-sm font-semibold"> <div className="min-w-0 flex-1">
{ceoAgent?.name ?? "Board Room"} <h3 className="text-sm font-semibold">
</h3> {ceoAgent?.name ?? "Board Room"}
<p className="text-xs text-muted-foreground"> </h3>
{selectedCompany?.name ?? "Your company"} <p className="text-xs text-muted-foreground">
</p> {selectedCompany?.name ?? "Your company"}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="chat history"
>
<History className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">chat history</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="new chat"
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">new chat</TooltipContent>
</Tooltip>
</div>
</div> </div>
{/* Messages — scroll viewport flush right so the scrollbar sits on the pane/divider edge */} {/* Messages — scroll viewport flush right so the scrollbar sits on the pane/divider edge */}
<div className="scrollbar-auto-hide min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"> <div className="scrollbar-auto-hide min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
@ -374,7 +458,7 @@ export function BoardChat() {
: "bg-muted text-foreground [border-radius:12px_12px_12px_0px]", : "bg-muted text-foreground [border-radius:12px_12px_12px_0px]",
)} )}
> >
<MarkdownBody className={BOARD_CHAT_MARKDOWN_SINGLE_LINE}> <MarkdownBody className={BOARD_CHAT_MARKDOWN_CLASS}>
{comment.body ?? ""} {comment.body ?? ""}
</MarkdownBody> </MarkdownBody>
</div> </div>
@ -388,7 +472,7 @@ export function BoardChat() {
<div <div
className={cn( className={cn(
boardChatBubbleShell, boardChatBubbleShell,
"whitespace-nowrap bg-blue-600 text-white [border-radius:12px_12px_0px_12px]", "bg-blue-600 text-white [border-radius:12px_12px_0px_12px]",
)} )}
> >
{optimisticMessage} {optimisticMessage}
@ -405,7 +489,7 @@ export function BoardChat() {
"bg-muted text-foreground [border-radius:12px_12px_12px_0px]", "bg-muted text-foreground [border-radius:12px_12px_12px_0px]",
)} )}
> >
<MarkdownBody className={BOARD_CHAT_MARKDOWN_SINGLE_LINE}>{streamingText}</MarkdownBody> <MarkdownBody className={BOARD_CHAT_MARKDOWN_CLASS}>{streamingText}</MarkdownBody>
</div> </div>
</div> </div>
)} )}
@ -483,17 +567,22 @@ export function BoardChat() {
</p> </p>
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
type="button" <DropdownMenuTrigger asChild>
variant="ghost" <Button
size="icon-sm" type="button"
className="shrink-0 text-muted-foreground" variant="ghost"
aria-label="Filter agent feed" size="icon-sm"
> className="shrink-0 text-muted-foreground"
<ListFilter /> aria-label="filter by"
</Button> >
</DropdownMenuTrigger> <ListFilter />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">filter by</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
value={agentFeedFilter} value={agentFeedFilter}