Files
paperclip/ui/src/pages/BoardChat.tsx
T
scotttong 6f9801a46b feat(ui): NUX rework behind enableConferenceRoomChat experimental flag — capsule onboarding, conference-room chat, unified composer (#8000)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The first-run experience (onboarding wizard) and the chat surfaces
(conference-room/board chat, task threads, composers) are the product's
front door — they decide whether a new operator understands "hire
agents, give them work, review results" in the first five minutes
> - Today those surfaces feel ticket-y and form-like: the wizard is a
static multi-step form that ends in an anticlimactic "Launch" screen,
the task composer and board chat behave differently from each other, and
agent-feed issue quicklooks misbehave (multiple flyouts open at once,
cards jump on hover)
> - We wanted to iterate toward a conversational, team-centric NUX — but
without risking the workflows of everyone already running Paperclip
> - This PR reworks the NUX behind a new default-OFF
`enableConferenceRoomChat` experimental flag: a capsule-motif onboarding
wizard that builds your team as you answer, a conference-room chat
surface, one shared ChatComposer across surfaces, brand-accurate status
chips, and feed-quicklook fixes — with the pre-existing UI
fork-and-frozen as `*Classic` components that flag-OFF users keep
> - The benefit is a complete, testable modern NUX that anyone can opt
into from Settings → Experimental, with zero default behavior change and
a clean path to either graduate or drop the experiment

## Linked Issues or Issue Description

No pre-existing GitHub issue — feature description per
`feature_request.yml`:

- **Problem / motivation:** Paperclip's onboarding wizard and chat
surfaces grew up as separate ticket-centric forms. New users get a
form-filling experience rather than the feeling of standing up a team;
the board chat and task threads use different composers with different
affordances; the agent feed's issue quicklook can stack multiple
popovers and shifts cards on hover.
- **Proposed solution:** A coherent NUX experiment behind one
experimental flag (`enableConferenceRoomChat`, Settings → Experimental,
default OFF): capsule onboarding wizard with an evolving team capsule,
conference-room chat, unified `ChatComposer`, team-centric copy, brand
status chips, quicklook single-flight fix. Flag-OFF users get the exact
pre-experiment UI via frozen `*Classic` forks, verified by an on/off
parity test matrix.
- **Alternatives considered:** (a) incremental unflagged restyling —
rejected: the changes interlock across surfaces and would drip risk into
every release; (b) a separate app shell / route for the new NUX —
rejected: too much divergence, the flag + classic-fork pattern keeps the
diff reviewable and reversible.
- **Roadmap alignment:** `ROADMAP.md` lists **CEO Chat** ("a
lighter-weight way to talk to leadership agents... should still resolve
to real work objects"). This experiment is groundwork in that direction
(conference-room chat resolves to issues/tasks via the same composer
used in task threads) and does not change the core task-and-comments
model.

Related PRs found in the dedup search (same area, none duplicate this
work — they target the classic wizard, which this PR intentionally
leaves intact and mergeable):

- #5385 — Coach-driven onboarding: conversational entry +
agent-companies package import
- #5378 — Onboarding wizard: reusable adapter picker + probe card
- #6636 — ui(onboarding): friendly error surface + retry for the wizard
- #7005 — fix(onboarding): explicitly await first-task wake
- #2616 — fix: restore workspace directory config in onboarding wizard

## What Changed

- **Experimental flag plumbing** — `enableConferenceRoomChat` in shared
types/validators, server instance-settings service + API, Settings →
Experimental card with explicit enable/disable copy
- **Onboarding wizard** — classic wizard forked and frozen
(`OnboardingWizardClassic`); flag-ON variant is a 5-step capsule wizard
with a persistent evolving `AgentCapsule` (gradient/glow motif),
team-centric reframed copy, and a typing-dots intro (hardened with
fake-timer tests)
- **Conference-room chat** — flag-ON board-chat surface with agent
bubble name/icon headers and copy/vote/timestamp action rows
(`AgentBubbleActionRow`)
- **Unified composer** — shared `ChatComposer` adopted across surfaces;
translucent surface + scroll-mask removal; "Agent mode"/"Plan mode"
relabels; no-assignee confirmation `AlertDialog` (new
`ui/alert-dialog.tsx` primitive); `@task` reference picker +
linkification in mentions
- **Agent feed** — single-flight issue-quicklook store (one popover at a
time), flyouts open to the left, removed hover translate-y jitter
- **Status chips** — brand-accurate task status chips behind the flag
(light/dark, 1px borders per paperclip.ing/brand)
- **Tests** — flag on/off parity matrix across IssueDetail,
NewIssueDialog, Sidebar, wizard, gate components; component tests for
all new pieces
- **Merge with `master`** — one conflict in
`ui/src/components/IssueChatThread.tsx`, resolved by keeping master's
new `AssigneeChip`/`HandoffWakeRow`/`RunStatusBadge` components inside
the flag-gated metadata-row chrome (details in commit `21a5642a`);
post-merge fixes: vitest 4 mock typing in `MarkdownEditor.test.tsx`,
flag hook made safe for provider-less mounts (master's new isolated
component tests)
- **Branch hygiene** — internal design wireframes/mockups stripped
before the PR (they live in the Paperclip issue threads)
- No user-facing documentation changes required: the flag is
intentionally experimental and self-described in the Settings card; no
existing docs reference the affected surfaces

## Verification

- `pnpm run typecheck` — green across the workspace (ui, server, shared,
plugins)
- Full UI suite (`vitest run` in `ui/`, clean worktree at this HEAD):
**1593/1595 passing, 223/224 files** — the 2 remaining failures are in
`src/components/artifacts/ArtifactCard.test.tsx` and **fail identically
on pristine `origin/master`** (pre-existing upstream, unrelated to this
branch)
- Full server suite (`vitest run` in `server/`, same clean worktree):
results in PR checks; flag plumbing covered by instance-settings tests
- Targeted post-merge resolution check: `IssueChatThread`,
`IssueChatThreadSystemNotice`, `IssueDetail`, `Sidebar`,
`ConferenceRoomChatGate`, `OnboardingWizardVariant`, `NewIssueDialog`,
`InstanceExperimentalSettings`, `MarkdownEditor` — 172/172 passing
- Manual walkthrough: flag OFF (default) → onboarding wizard, task
thread, board chat, composer all render the classic UI; flag ON via
Settings → Experimental → capsule wizard, conference-room chat, unified
composer, status chips active
- Screenshots: see below

**Flag on/off screenshots** (committed on this branch under
`screenshots/PR-8000-*`):

| Surface | Flag OFF (classic, default) | Flag ON (experimental) |
| --- | --- | --- |
| Settings → Experimental | ![settings
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-settings-experimental-flag-off.png)
| ![settings
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-settings-experimental-flag-on.png)
|
| Task thread | ![thread
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-task-thread-flag-off.png)
| ![thread
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-task-thread-flag-on.png)
|
| Home / nav | ![home
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-home-flag-off.png)
| ![home
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-home-flag-on.png)
|
| Conference Room (flag-ON only surface) | — | ![conference
room](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-conference-room-flag-on.png)
|

Capsule onboarding wizard walkthrough screenshots (flag ON) are attached
to the Paperclip design/implementation threads; the wizard requires a
fresh instance so it is captured via the e2e harness
(`tests/e2e/nux-phase4-screenshots.spec.ts`).


## Risks

- **Large surface, but gated:** all new behavior sits behind
`enableConferenceRoomChat`, default OFF; flag-OFF rendering is locked by
frozen `*Classic` forks plus an on/off parity test suite
- **Classic forks are frozen at the fork point (`e3aada1d`):** master
features added to the live thread component after that point (assignee
handoff chips, run status badge, composer mention coach) render in the
flag-ON path; the flag-OFF task thread keeps the fork-point behavior
until the experiment graduates (forks deleted) or is dropped (forks
restored as canonical). Called out for reviewer attention.
- **Merge-conflict resolution in `IssueChatThread.tsx`** (commit
`21a5642a`) deserves reviewer eyes: master's new handoff/run-status
components were kept; the base toast-style no-assignee flow remains
replaced by the AlertDialog flow introduced on this branch
- Schema/server changes are additive (one optional boolean instance
setting); no migrations of existing data

## Model Used

- Claude (Anthropic) via Claude Code running in the Paperclip agent
harness (agent: ClaudeCoder)
- Branch implemented across multiple agent sessions on Claude Opus-class
models with extended thinking + tool use (file edits, shell, Playwright
screenshots); merge/PR session model ID as reported by the harness:
`claude-fable-5` (Claude Code CLI)
- All code was agent-authored and board-reviewed through Paperclip issue
threads (plans, wireframes, confirmations) before merging

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes (none
required — experimental flag, self-documenting Settings card; noted
above)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (run 3 on `8af3041a`: all 16
gates SUCCESS, incl. e2e and all 4 serialized-suite shards)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(re-review verdict: Confidence 5/5, “Safe to merge”; all 4 round-1
findings fixed + confirmed resolved; both summary notes addressed in
`8af3041a`)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:32:55 -05:00

1021 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useLayoutEffect,
useState,
useRef,
useCallback,
useMemo,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useDialogState } from "../context/DialogContext";
import { agentsApi } from "../api/agents";
import { issuesApi } from "../api/issues";
import { goalsApi } from "../api/goals";
import { queryKeys } from "../lib/queryKeys";
import { MarkdownBody } from "../components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Activity, ArrowDown, History, MessageSquarePlus, X } from "lucide-react";
import { ActivityFeed } from "../components/ActivityFeed";
import { ChatComposer, type ChatComposerHandle } from "../components/ChatComposer";
import {
AgentBubbleActionRow,
agentBubbleDateLabel,
} from "../components/AgentBubbleActionRow";
import { AgentIcon } from "../components/AgentIconPicker";
import { cn, formatDateTime } from "../lib/utils";
import type { FeedbackVoteValue } from "@paperclipai/shared";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
/**
* Board Concierge Chat — a chat interface powered by the board-member skill.
* Uses /board/chat/stream to invoke Claude with the board skill as system prompt.
* The user manages their Paperclip company through natural conversation.
*/
/** Hit zone to the right of the 1px line (line sits on chat panes right edge). */
const SPLIT_DIVIDER_PX = 12;
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;
/** Wrapped markdown in bubbles; pre/table scroll horizontally when needed. */
const BOARD_CHAT_MARKDOWN_CLASS =
"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%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible";
/** First-letter(s) fallback for an agent with no icon. */
function agentInitials(name: string): string {
const parts = name.trim().split(/\s+/);
return (((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase()) || "A";
}
/**
* Icon-adjacent-to-name header rendered directly above an agent bubble —
* the shared `[agent icon][agent name]` convention (PAP-105 / PAP-97).
*/
function AgentBubbleHeader({ name, icon }: { name: string; icon: string | null }) {
return (
<div className="mb-1 flex items-center gap-1.5 pl-1">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>
{icon ? (
<AgentIcon icon={icon} className="h-3.5 w-3.5" />
) : (
agentInitials(name)
)}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium text-foreground">{name}</span>
</div>
);
}
/** Agent-styled chat bubble containing the three-dot typing indicator. */
function TypingBubble() {
return (
<div className="flex justify-start">
<div
className={cn(
boardChatBubbleShell,
"bg-card border border-border text-foreground [border-radius:14px_14px_14px_4px]",
)}
>
<span className="typing-dots" aria-label="typing">
<span />
<span />
<span />
</span>
</div>
</div>
);
}
export function BoardChat() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
useEffect(() => {
setBreadcrumbs([{ label: "Conference Room" }]);
}, [setBreadcrumbs]);
const splitContainerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [chatPaneFraction, setChatPaneFraction] = useState(DEFAULT_CHAT_FRACTION);
const splitDragging = useRef(false);
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(
(e: React.MouseEvent) => {
e.preventDefault();
splitDragging.current = true;
const startX = e.clientX;
const startWidth = leftPaneWidth;
const onMouseMove = (ev: MouseEvent) => {
if (!splitDragging.current) return;
const containerW = splitContainerRef.current?.clientWidth ?? containerWidth;
const inner = containerW - SPLIT_DIVIDER_PX;
const lower = SPLIT_MIN_PANE_PX;
const upper = inner - SPLIT_MIN_PANE_PX;
const next = startWidth + ev.clientX - startX;
if (inner <= 0) return;
if (upper < lower) {
setChatPaneFraction(0.5);
} else {
const clamped = Math.min(upper, Math.max(lower, next));
setChatPaneFraction(clamped / inner);
}
};
const onMouseUp = () => {
splitDragging.current = false;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
[containerWidth, leftPaneWidth],
);
const [input, setInput] = useState("");
/** Guards the draft-persistence effect so it doesn't overwrite a saved
* draft with "" before we've had a chance to load it. */
const loadedDraftCompanyRef = useRef<string | null>(null);
const [sending, setSending] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [statusText, setStatusText] = useState("");
const [errorText, setErrorText] = useState("");
const [boardIssueId, setBoardIssueId] = useState<string | null>(null);
const [elapsedSec, setElapsedSec] = useState(0);
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const hasRestoredScrollRef = useRef(false);
const composerRef = useRef<ChatComposerHandle>(null);
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** True when the user is scrolled away from the bottom AND new content
* has arrived they can't see. Drives the floating "jump to latest" chip. */
const [hasNewBelow, setHasNewBelow] = useState(false);
/** Tracks whether the user was near the bottom BEFORE the latest content
* change. Updated on scroll events (and after programmatic scrolls) so
* that when a tall new message inflates scrollHeight, we still know the
* user's pre-update position and can decide whether to auto-scroll. */
const wasNearBottomRef = useRef(true);
const scrollToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
messagesEndRef.current?.scrollIntoView({ behavior, block: "end" });
wasNearBottomRef.current = true;
setHasNewBelow(false);
}, []);
// Welcome typing intro: staged reveal of typing → welcome bubble → chips.
// The timers don't start until the data needed to render the welcome is
// actually loaded, so the animation plays at the moment the user arrives
// at the chat (e.g. right after creating a new company) rather than
// burning off while a spinner is on screen.
const [welcomeRevealed, setWelcomeRevealed] = useState(false);
const [chipsRevealed, setChipsRevealed] = useState(false);
// Reset state and clear cached comments when company changes. The
// composer draft is NOT wiped — it's loaded from per-company
// sessionStorage in the effect below so users don't lose typed content
// when switching between companies or navigating away and back.
const prevCompanyRef = useRef(selectedCompanyId);
useEffect(() => {
if (prevCompanyRef.current !== selectedCompanyId) {
if (boardIssueId) {
queryClient.removeQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
}
setBoardIssueId(null);
setStreamingText("");
setStatusText("");
setSending(false);
setOptimisticMessage(null);
prevCompanyRef.current = selectedCompanyId;
}
}, [selectedCompanyId, boardIssueId, queryClient]);
// Load a saved composer draft (if any) whenever the active company
// changes — runs on first mount too.
useEffect(() => {
if (!selectedCompanyId) return;
if (loadedDraftCompanyRef.current === selectedCompanyId) return;
try {
const saved = sessionStorage.getItem(
`paperclip.boardChat.draft.${selectedCompanyId}`,
);
setInput(saved ?? "");
} catch {
setInput("");
}
loadedDraftCompanyRef.current = selectedCompanyId;
}, [selectedCompanyId]);
// Persist composer draft to sessionStorage on change (per company).
// Only runs after the initial load for this company to avoid clobbering
// a saved draft with an empty initial value.
useEffect(() => {
if (!selectedCompanyId) return;
if (loadedDraftCompanyRef.current !== selectedCompanyId) return;
try {
const key = `paperclip.boardChat.draft.${selectedCompanyId}`;
if (input) {
sessionStorage.setItem(key, input);
} else {
sessionStorage.removeItem(key);
}
} catch { /* sessionStorage unavailable */ }
}, [input, selectedCompanyId]);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const ceoAgent = useMemo(
() => agents?.find((a) => a.role === "ceo" && a.status !== "terminated"),
[agents],
);
// Pull the company's top-level goal so the CEO's welcome can reference
// the mission verbatim.
const { data: goals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const missionText = useMemo(() => {
const active = (goals ?? []).find((g) => g.status === "active");
return active?.title ?? null;
}, [goals]);
// Find or detect the board operations issue
const { data: issues } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
useEffect(() => {
if (!issues) {
setBoardIssueId(null);
return;
}
const boardIssue = issues.find(
(i) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
);
setBoardIssueId(boardIssue?.id ?? null);
}, [issues]);
// Fetch comments for the board issue
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(boardIssueId ?? ""),
queryFn: () => issuesApi.listComments(boardIssueId!),
enabled: !!boardIssueId,
refetchInterval: 3000,
});
const sortedComments = (comments ?? [])
.slice()
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
// Agent lookup so each bubble can show its author's name + icon header.
const agentMap = useMemo(
() => new Map((agents ?? []).map((a) => [a.id, a] as const)),
[agents],
);
// Feedback votes for the board issue power the 👍/👎 affordance — the same
// store the task thread reads (PAP-105 shares the action row).
const { data: feedbackVotes } = useQuery({
queryKey: queryKeys.issues.feedbackVotes(boardIssueId ?? ""),
queryFn: () => issuesApi.listFeedbackVotes(boardIssueId!),
enabled: !!boardIssueId,
});
const voteByComment = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const vote of feedbackVotes ?? []) {
if (vote.targetType === "issue_comment") map.set(vote.targetId, vote.vote);
}
return map;
}, [feedbackVotes]);
const handleCommentVote = useCallback(
async (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => {
if (!boardIssueId) return;
await issuesApi.upsertFeedbackVote(boardIssueId, {
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.feedbackVotes(boardIssueId),
});
},
[boardIssueId, queryClient],
);
// Reset the staged reveal on mount AND whenever the active company
// changes, so every arrival at the Conference Room replays the typing
// intro from scratch (a freshly-created company included). The effect's
// mount run is intentional — it keeps the intro fresh even if a future
// refactor preserves this component instance across navigations (PAP-134).
useEffect(() => {
setWelcomeRevealed(false);
setChipsRevealed(false);
}, [selectedCompanyId]);
// The onboarding wizard renders as an overlay above an already-mounted
// Conference Room (sidebar "Create new team..." path). Holding the reveal
// timer while it's open guarantees the dots window can't burn off behind
// the wizard before the user ever sees the chat (PAP-134).
const { onboardingOpen } = useDialogState();
// Likewise, don't let the dots window burn while the tab is hidden —
// e.g. the user completes the wizard, switches tabs, and comes back.
const [pageVisible, setPageVisible] = useState(
() => document.visibilityState !== "hidden",
);
useEffect(() => {
const onVisibilityChange = () =>
setPageVisible(document.visibilityState !== "hidden");
document.addEventListener("visibilitychange", onVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", onVisibilityChange);
}, []);
// Start the typing → welcome timer only once we have the ingredients
// needed to render the welcome bubble. This guarantees the animation is
// visible at the moment the user arrives, even if agent/goal queries
// take a beat to resolve.
const canRenderWelcome = !!ceoAgent && !!selectedCompany;
useEffect(() => {
if (!canRenderWelcome) return;
if (welcomeRevealed) return;
if (onboardingOpen || !pageVisible) return;
const timeout = setTimeout(() => setWelcomeRevealed(true), 2000);
return () => clearTimeout(timeout);
}, [canRenderWelcome, welcomeRevealed, onboardingOpen, pageVisible]);
// Stage the suggestion chips in shortly after the welcome bubble lands
// so the eye reads the message first, then the actions.
useEffect(() => {
if (!welcomeRevealed) return;
if (chipsRevealed) return;
const timeout = setTimeout(() => setChipsRevealed(true), 700);
return () => clearTimeout(timeout);
}, [welcomeRevealed, chipsRevealed]);
// If the user has already replied in this conversation, fast-forward
// past the intro — the welcome isn't a "new" event anymore.
useEffect(() => {
if (welcomeRevealed && chipsRevealed) return;
if (!comments) return;
const userHasReplied = comments.some(
(c) => !c.authorAgentId && c.authorUserId !== "board-concierge",
);
if (userHasReplied) {
setWelcomeRevealed(true);
setChipsRevealed(true);
}
}, [comments, welcomeRevealed, chipsRevealed]);
// Clear optimistic message once server-persisted comments include it
useEffect(() => {
if (optimisticMessage && sortedComments.length > 0) {
const lastUserComment = [...sortedComments]
.reverse()
.find((c) => !c.authorAgentId && c.authorUserId !== "board-concierge");
if (lastUserComment?.body === optimisticMessage) {
setOptimisticMessage(null);
}
}
}, [sortedComments, optimisticMessage]);
// Scroll behavior:
// - First mount in a session (no saved position): jump to bottom instantly.
// - Returning to the page within the same session: restore last scrollTop.
// - New content arriving: smooth-scroll to bottom only if user is already
// near the bottom, so we don't yank them away from reading history.
// - Scroll position is persisted to sessionStorage (cleared when tab closes).
useEffect(() => {
if (hasRestoredScrollRef.current) return;
if (sortedComments.length === 0) return;
const container = scrollContainerRef.current;
if (!container) return;
try {
const saved = sessionStorage.getItem("paperclip.boardChat.scrollTop");
if (saved != null) {
const parsed = Number(saved);
if (Number.isFinite(parsed)) {
container.scrollTop = parsed;
hasRestoredScrollRef.current = true;
return;
}
}
} catch { /* sessionStorage unavailable */ }
container.scrollTop = container.scrollHeight;
hasRestoredScrollRef.current = true;
}, [sortedComments.length]);
// User sent a message: always scroll so their just-typed message is in
// view, even if they were scrolled up reading history.
useEffect(() => {
if (!optimisticMessage) return;
scrollToLatest("smooth");
}, [optimisticMessage, scrollToLatest]);
// Agent activity (new persisted comment, streaming chunks, status):
// auto-scroll only if the user was near the bottom BEFORE the new content
// arrived. Using the ref (updated on scroll events) instead of measuring
// after the render, because the new content has already grown scrollHeight
// by the time this effect fires — making the post-update "distance from
// bottom" misleading.
useEffect(() => {
if (!hasRestoredScrollRef.current) return;
if (wasNearBottomRef.current) {
scrollToLatest("smooth");
} else {
setHasNewBelow(true);
}
}, [sortedComments.length, streamingText, statusText, scrollToLatest]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
let rafId: number | null = null;
const handleScroll = () => {
const near = container.scrollHeight - container.scrollTop - container.clientHeight <= 80;
wasNearBottomRef.current = near;
if (near) setHasNewBelow(false);
if (rafId != null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
try {
sessionStorage.setItem(
"paperclip.boardChat.scrollTop",
String(container.scrollTop),
);
} catch { /* sessionStorage unavailable */ }
});
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
if (rafId != null) cancelAnimationFrame(rafId);
};
}, []);
// Elapsed timer for thinking state — tick at 100ms so the tenths place
// updates smoothly and the wait feels quicker than a whole-second counter.
useEffect(() => {
if (sending) {
setElapsedSec(0);
const startedAt = Date.now();
elapsedTimerRef.current = setInterval(() => {
setElapsedSec((Date.now() - startedAt) / 1000);
}, 100);
} else {
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current);
elapsedTimerRef.current = null;
}
}
return () => {
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
};
}, [sending]);
const sendMessage = useCallback(
async (body: string) => {
const trimmed = body.trim();
if (!trimmed || sending || !selectedCompanyId) return;
// Show user message immediately
setOptimisticMessage(trimmed);
setSending(true);
setInput("");
setStreamingText("");
setErrorText("");
setStatusText("Connecting...");
try {
const controller = new AbortController();
const fetchTimeout = setTimeout(() => controller.abort(), 130000);
const res = await fetch("/api/board/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyId: selectedCompanyId,
message: trimmed,
taskId: boardIssueId ?? undefined,
}),
signal: controller.signal,
});
clearTimeout(fetchTimeout);
if (!res.ok || !res.body) {
throw new Error("Board chat stream not available");
}
setStatusText("Thinking...");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const event = JSON.parse(line.slice(6));
if (event.type === "chunk" && event.text) {
accumulated += event.text;
setStreamingText(accumulated);
setStatusText("");
} else if (event.type === "status" && event.text) {
setStatusText(event.text);
} else if (event.type === "start" && event.issueId) {
setBoardIssueId(event.issueId);
} else if (event.type === "error") {
setErrorText(
event.message ||
"The board assistant couldn't respond. Please try again.",
);
setStatusText("");
} else if (event.type === "done") {
if (event.issueId) {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.comments(event.issueId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(selectedCompanyId),
});
}
}
} catch {
/* malformed SSE line */
}
}
}
setStreamingText("");
setStatusText("");
if (boardIssueId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
}
} catch (err) {
console.error("Board chat error:", err);
setStatusText("");
setErrorText(
"The board assistant is unavailable right now. Please try again in a moment.",
);
} finally {
setSending(false);
composerRef.current?.focus();
}
},
[sending, selectedCompanyId, boardIssueId, queryClient],
);
const handleSend = useCallback(() => {
sendMessage(input);
}, [input, sendMessage]);
// NOTE: declared before the early return below — all hooks must run on
// every render (Rules of Hooks). Placing it after the `!selectedCompanyId`
// guard caused "Rendered more hooks than during the previous render" and a
// blank page once a company was selected.
const [mobileFeedOpen, setMobileFeedOpen] = useState(false);
if (!selectedCompanyId) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-sm">
<h2 className="text-lg font-semibold">No company selected</h2>
<p className="text-sm text-muted-foreground mt-2">
Select a company to start chatting with your board concierge.
</p>
</div>
</div>
);
}
return (
<div className="flex h-[calc(100%+3rem)] flex-col -m-6">
<div
ref={splitContainerRef}
className="flex min-h-0 min-w-0 flex-1 flex-row"
>
{/* Left: chat (self-contained pane) — full width on mobile, 2/3 default on desktop */}
<div
className={cn(
"relative flex min-h-0 min-w-0 shrink-0 flex-col bg-background",
"w-full md:w-auto",
innerWidth <= 0 && "md:w-2/3",
)}
style={innerWidth > 0 && containerWidth >= 2 * SPLIT_MIN_PANE_PX + SPLIT_DIVIDER_PX ? { width: leftPaneWidth } : undefined}
>
<div className="relative flex shrink-0 items-center justify-between gap-2 px-4 py-3">
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
aria-hidden
/>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold">
{ceoAgent?.name ?? "Conference Room"}
</h3>
<p className="text-xs text-muted-foreground">
{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>
{/* Messages — scroll viewport flush right so the scrollbar sits on the pane/divider edge */}
<div className="relative min-h-0 min-w-0 flex-1">
<div
ref={scrollContainerRef}
className="scrollbar-auto-hide absolute inset-0 overflow-y-auto overflow-x-hidden"
>
{/* pb clears the floating glass dock (PAP-131) so the last bubble can
scroll fully above the composer. */}
<div className="flex flex-col gap-4 px-6 pt-3 pb-32">
{/* Typing bubble — shown unconditionally until the reveal
timer fires, so the animation is guaranteed to be
visible even while agent/goal data is still loading. */}
{!welcomeRevealed && <TypingBubble />}
{welcomeRevealed && ceoAgent && selectedCompany && (() => {
const ceoName = ceoAgent.name;
const companyName = selectedCompany.name;
const missionLine = missionText
? ` — your mission is "${missionText}".`
: ".";
const welcomeBody =
`Welcome to **${companyName}**! I'm ${ceoName}, your team lead. I've read through what you shared in the wizard${missionLine}\n\n` +
`Here are a few things I can help you put on paper right now. Pick one below and I'll draft it for you using everything you told us.`;
const userHasReplied = sortedComments.some(
(c) => !c.authorAgentId && c.authorUserId !== "board-concierge",
);
const chips: Array<{ label: string; prompt: string }> = [
{
label: "Draft a Company Brief",
prompt: `Draft a one-page Company Brief for ${companyName} — include our mission, team roster, and first priorities.`,
},
{
label: "Create a hiring plan",
prompt: `Create a hiring plan for ${companyName}. List the next roles to hire, in priority order, with a short rationale for each.`,
},
{
label: "Outline our first 30 days",
prompt: `Outline our first 30 days. Break it into weekly priorities with who owns what.`,
},
{
label: "Write an intro pitch",
prompt: `Write a short intro pitch for ${companyName} that I could reuse for investors, customers, or recruits.`,
},
];
return (
<>
<div className="flex flex-col items-start">
<AgentBubbleHeader name={ceoName} icon={ceoAgent.icon} />
<div
className={cn(
boardChatBubbleShell,
"bg-card border border-border text-foreground [border-radius:14px_14px_14px_4px]",
)}
>
<MarkdownBody className={BOARD_CHAT_MARKDOWN_CLASS}>{welcomeBody}</MarkdownBody>
</div>
</div>
{!userHasReplied && chipsRevealed && (
<div className="flex flex-wrap gap-2 pl-1">
{chips.map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => {
setInput(chip.prompt);
composerRef.current?.focus();
}}
className="rounded-full border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground"
>
{chip.label}
</button>
))}
</div>
)}
</>
);
})()}
{sortedComments.map((comment) => {
const isUser = !comment.authorAgentId && comment.authorUserId !== "board-concierge";
if (isUser) {
return (
<div key={comment.id} className="flex justify-end">
<div
className={cn(
boardChatBubbleShell,
"bg-blue-600 text-white [border-radius:14px_14px_4px_14px]",
)}
>
{comment.body ?? ""}
</div>
</div>
);
}
// Agent bubble — name/icon header above + action row below so
// the room speaks the same bubble language as the task thread.
const agent = comment.authorAgentId
? agentMap.get(comment.authorAgentId) ?? null
: ceoAgent ?? null;
const agentName = agent?.name ?? "Assistant";
const agentIconValue = agent?.icon ?? null;
return (
<div key={comment.id} className="flex flex-col items-start">
<AgentBubbleHeader name={agentName} icon={agentIconValue} />
<div
className={cn(
boardChatBubbleShell,
"bg-card border border-border text-foreground [border-radius:14px_14px_14px_4px]",
)}
>
<MarkdownBody className={BOARD_CHAT_MARKDOWN_CLASS}>
{comment.body ?? ""}
</MarkdownBody>
</div>
<AgentBubbleActionRow
copyText={comment.body ?? ""}
dateLabel={agentBubbleDateLabel(comment.createdAt)}
dateTitle={formatDateTime(comment.createdAt)}
anchorHref={`#comment-${comment.id}`}
feedback={
boardIssueId
? {
activeVote: voteByComment.get(comment.id) ?? null,
sharingPreference: "prompt",
termsUrl: null,
onVote: (vote, options) =>
handleCommentVote(comment.id, vote, options),
}
: null
}
/>
</div>
);
})}
{/* Optimistic user message — shows instantly before server persists */}
{optimisticMessage && (
<div className="flex justify-end">
<div
className={cn(
boardChatBubbleShell,
"bg-blue-600 text-white [border-radius:14px_14px_4px_14px]",
)}
>
{optimisticMessage}
</div>
</div>
)}
{/* Streaming response */}
{streamingText && (
<div className="flex flex-col items-start">
{ceoAgent && (
<AgentBubbleHeader name={ceoAgent.name} icon={ceoAgent.icon} />
)}
<div
className={cn(
boardChatBubbleShell,
"bg-card border border-border text-foreground [border-radius:14px_14px_14px_4px]",
)}
>
<MarkdownBody className={BOARD_CHAT_MARKDOWN_CLASS}>{streamingText}</MarkdownBody>
</div>
</div>
)}
{/* Typing bubble — sits above the status line while the agent
is preparing a reply but no text has streamed yet. Shows
alongside the user's optimistic bubble to make the
turn-taking feel alive. */}
{sending && !streamingText && <TypingBubble />}
{/* Status bar — always visible while sending, independent from the chat bubble */}
{sending && (
<div className="flex items-center gap-2 pl-1 text-xs text-muted-foreground">
<img src="/paperclip-thinking.svg" alt="" className="inline-block shrink-0" style={{ width: 14, height: 14 }} />
<span>{statusText || "Thinking..."}</span>
{elapsedSec > 0 && (
<span className="opacity-50">{elapsedSec.toFixed(1)}s</span>
)}
</div>
)}
{/* Error notice — surfaced when the stream endpoint fails so
the message doesn't silently sit with no response. */}
{errorText && !sending && (
<div
role="alert"
className="flex justify-start"
>
<div
className={cn(
boardChatBubbleShell,
"bg-destructive/10 border border-destructive/30 text-destructive [border-radius:14px_14px_14px_4px]",
)}
>
{errorText}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Jump-to-latest — shows when user is scrolled away and new content has arrived */}
{hasNewBelow && (
<button
type="button"
onClick={() => scrollToLatest("smooth")}
aria-label="Jump to latest messages"
className="absolute bottom-24 left-1/2 z-20 grid h-8 w-8 -translate-x-1/2 place-items-center rounded-full border border-border bg-card text-foreground shadow-md transition-colors duration-150 hover:bg-accent hover:border-muted-foreground/30"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
{/* Input — shared ChatComposer (PAP-95a), adopted bare: textarea + send.
No mode chip (the room has no task lifecycle). Multiline like task
comments (PAP-116): text soft-wraps and the box auto-grows instead of
clipping / showing a horizontal scrollbar. Sends on plain Enter today
(Shift+Enter for a newline); flipping to ⌘/Ctrl+Enter is pending board
confirmation.
PAP-131 (PAP-128 A): the dock floats over the message stream so text
scrolls behind the translucent glass box. The old hard black gradient
mask is gone — the dock carries the task-style soft top fade instead
(mirrors IssueChatThread's composer dock). pointer-events pass through
the fade so the scrollbar stays usable; the composer re-enables them. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-background via-background/95 to-background/0 px-6 pt-6 pb-5">
<ChatComposer
ref={composerRef}
value={input}
onChange={setInput}
onSubmit={handleSend}
placeholder="Ask anything about your company..."
submitKey="enter"
surface="translucent"
submitting={sending}
disabled={sending}
sendLabel="Send message"
className="pointer-events-auto"
/>
</div>
</div>
{/* Resize handle — hidden on mobile */}
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize board chat and agent feed"
className="group relative hidden w-3 shrink-0 cursor-col-resize bg-background md:flex"
onMouseDown={handleSplitDragStart}
>
<div
className="pointer-events-none absolute top-0 bottom-0 left-0 w-px bg-border transition-colors group-hover:bg-foreground/20"
aria-hidden
/>
</div>
{/* Right: Agent Feed — hidden on mobile */}
<div className="hidden md:flex md:min-h-0 md:min-w-0 md:flex-1">
<ActivityFeed />
</div>
</div>
{/* Mobile: floating feed toggle + sheet drawer */}
<div className="md:hidden">
<Sheet open={mobileFeedOpen} onOpenChange={setMobileFeedOpen}>
<SheetTrigger asChild>
<Button
type="button"
size="icon"
variant="secondary"
className="fixed bottom-20 right-4 z-20 h-10 w-10 rounded-full shadow-lg"
aria-label="Open agent feed"
>
<Activity className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-[70vh] p-0 rounded-t-xl">
<ActivityFeed />
</SheetContent>
</Sheet>
</div>
</div>
);
}