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
26 changes: 26 additions & 0 deletions frontend/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,30 @@ Retry" rows.
`ApiError` message to `Error: …`; use `notify.error(messageOf(e))`. Don't
hand-roll full-page error markup when `<ErrorState>` fits.

### 2.18 Action icons — semantic convention

One glyph, one action family. A `+`-shaped icon is not a generic "do
something here" mark — it specifically means **create a brand-new resource**.
Attaching, uploading, or adding an *existing* thing each has its own glyph, so
two different actions never share a mark on the same surface (the bug this
codifies: the Workbench file panel once drew *create-file* and *add-to-context*
both as a bare `Plus`).

| Action family | Icon | Applies to |
|---|---|---|
| Create a new resource | `Plus` (bare) | file, folder, session, channel, DM, workspace, permission rule/grant, table row, kanban task |
| Create rooted at a folder | `FolderPlus` | a session rooted at a directory (RemoteWorkspace tree) |
| Attach / upload a file to the message | `Paperclip` | composer "Attach file" (upload / pick channel file), channel files |
| Add a Cheers resource to context | `MessageSquarePlus` | workbench file, ViewBoard, workspace-file reference, suggested-context chip, the composer "Add context" menu — the bundle rides your *next message* |
| Add a selected passage to context | `TextQuote` | a ranged `fs.read` excerpt — a labeled sub-variant of add-to-context (keeps its "quote a passage" meaning) |
| Add a person | `UserPlus` | add friend, invite member (decorative adornments on member-search inputs use it too) |
| `+N` count / overflow | literal `+N` text, **no icon** | avatar-stack overflow, diff added-lines — a quantity, not an action |

Add-to-context is centralized in `AttachContextButton`
(`src/features/chat/context/ContextPickBar.tsx`) — reuse it rather than
hand-drawing an attach glyph. `Paperclip` is reserved: it never means
"add to context" (that pipeline is resource references, not uploaded files).


---

Expand Down Expand Up @@ -455,3 +479,5 @@ Reject in review:
- [ ] new tab / empty-state / spinner styles when §2 already has one
- [ ] `toast.error(String(e))` — use `notify.error(messageOf(e))` (§2.17)
- [ ] hand-rolled error banners / full-page error markup when §2.17 has a tier for it; 401 handling at a call site (the client classifier owns it)
- [ ] `Plus` on anything that isn't "create a brand-new resource" — add-to-context is `MessageSquarePlus`, attach-file is `Paperclip`, add-person is `UserPlus` (§2.18)
- [ ] a new add-to-context affordance drawn with any glyph other than `MessageSquarePlus` (or `TextQuote` for a ranged passage) — or bypassing `AttachContextButton` (§2.18)
115 changes: 103 additions & 12 deletions frontend/src/features/chat/ChannelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo, lazy, Suspense } fro
import { ArrowLeft, Hash, Users, Loader2, PanelRight, PanelLeftClose, PanelLeftOpen, Paperclip, FolderTree, Settings, LayoutDashboard, Reply, X, Copy, Forward, WifiOff } from "lucide-react";
import toast from "react-hot-toast";
import { listMessages, sendMessage } from "@/api/messages";
import { useContextPickStore, toBundle } from "./context/contextPick";
import { useContextPickStore, toBundle, type ContextItem } from "./context/contextPick";
import { ContextPickBar } from "./context/ContextPickBar";
import { listChannelMembers, markChannelRead, joinChannel } from "@/api/channels";
import { useChatStore } from "@/stores/chatStore";
Expand Down Expand Up @@ -114,6 +114,9 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
const [workspaceFocus, setWorkspaceFocus] = useState<PresenceFocus[]>([]);
// Composer session target: "" = Auto (mention routing → primary); else a session_id.
const [selectedSessionId, setSelectedSessionId] = useState("");
// The owning bot of the pinned session (null for Auto) — narrows the composer's
// model chip to the single bot a pinned session actually targets.
const [selectedSessionBotId, setSelectedSessionBotId] = useState<string | null>(null);
// Bots @mentioned in the current draft (from the composer), so we can show their
// mode/config controls inline when the caller is allowed to change them.
const [mentionedBots, setMentionedBots] = useState<MentionCandidate[]>([]);
Expand Down Expand Up @@ -160,6 +163,7 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
// can't synthesize a phantom bubble in the newly opened channel.
useEffect(() => {
setSelectedSessionId("");
setSelectedSessionBotId(null);
setMentionedBots([]);
setCommands([]);
setMembersOpen(false);
Expand Down Expand Up @@ -657,11 +661,51 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
const [refError, setRefError] = useState<string | null>(null);
// Jump-to-message request from ViewBoard history items (activity rows, audit
// cards). `nonce` lets a repeat click on the same row re-trigger the scroll.
// Best-effort: MessageList only scrolls when the message is loaded.
// If the target isn't in the loaded window (initial load is only the newest
// page), page older history in — bounded — until it appears, then focus; the
// old behavior just toasted "scroll up to load older history" at the user.
const [focusMsg, setFocusMsg] = useState<{ msgId: string; nonce: number } | null>(null);
const messagesRef = useRef<Message[]>(messages);
messagesRef.current = messages;
const jumpBackfillRef = useRef(false);
// × 50/page — comfortably covers the Activity board's 200-event window.
const JUMP_BACKFILL_PAGES = 8;
const jumpToMessage = useCallback(
(msgId: string) => setFocusMsg((prev) => ({ msgId, nonce: (prev?.nonce ?? 0) + 1 })),
[]
async (msgId: string) => {
const focus = () => setFocusMsg((prev) => ({ msgId, nonce: (prev?.nonce ?? 0) + 1 }));
if (messagesRef.current.some((m) => m.msg_id === msgId)) return focus();
if (!channel || jumpBackfillRef.current) return;
jumpBackfillRef.current = true;
const toastId = toast.loading("Loading older history…");
try {
let oldest = messagesRef.current[0];
for (let page = 0; page < JUMP_BACKFILL_PAGES && oldest; page++) {
const res = await listMessages(channel.channel_id, {
before: oldest.msg_id,
limit: 50,
});
const batch = res.messages ?? res.data ?? [];
setMessages((prev) => mergeMessages(prev, batch));
setHasMore(res.meta?.has_more_before ?? false);
// Focus in the same batch as setMessages: MessageList's focus effect
// runs after the commit that renders the new rows, so the anchor exists.
if (batch.some((m) => m.msg_id === msgId)) return focus();
const sorted = sortMessages(batch);
if (!sorted.length || !(res.meta?.has_more_before ?? false)) break;
oldest = sorted[0];
}
toast("Couldn't find that message in this channel's history", {
icon: "🔍",
id: "jump-not-found",
});
} catch {
toast.error("Couldn't load older messages — try again", { id: "load-older-failed" });
} finally {
toast.dismiss(toastId);
jumpBackfillRef.current = false;
}
},
[channel]
);
// "Open the ViewBoard on THIS board" request (same nonce pattern as focusMsg) —
// the session chip's "Manage sessions…" jumps straight to the Sessions board.
Expand All @@ -672,6 +716,37 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
setFocusBoard((prev) => ({ id: "sessions", nonce: (prev?.nonce ?? 0) + 1 }));
}, []);

// Add-context menu → open a side surface (untargeted) so the user can pick a
// file to attach from its own panel.
const browseWorkbench = useCallback(() => {
setWbTarget(undefined);
setWbOpen(true);
}, []);
const browseWorkspace = useCallback(() => {
setWsInit({});
setWsOpen(true);
}, []);
// A pending context chip → jump to where that resource actually lives: a
// Workbench file (`fs.read`) opens the Workbench focused on it; a bot's
// workspace file (`workspace.read`) opens the Remote workspace at that path.
const jumpToContextSource = useCallback((item: ContextItem) => {
if (item.verb === "fs.read") {
const path = item.params.path;
if (typeof path === "string") {
setWbTarget(path);
setWbOpen(true);
}
} else if (item.verb === "workspace.read") {
const botId = item.params.bot_id;
const path = item.params.path;
setWsInit({
botId: typeof botId === "string" ? botId : undefined,
path: typeof path === "string" ? path : undefined,
});
setWsOpen(true);
}
}, []);

// Stable handlers for the memoized drawers so a streaming re-render of ChannelView
// doesn't hand them fresh closures (which would defeat React.memo).
const closeViewBoard = useCallback(() => setVbOpen(false), []);
Expand All @@ -688,26 +763,39 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
channelId={channel.channel_id}
bots={switcherBots}
value={selectedSessionId}
onChange={setSelectedSessionId}
onChange={(sid, botId) => {
setSelectedSessionId(sid);
setSelectedSessionBotId(botId ?? null);
}}
sendResourceReq={sendResourceReq}
onManageSessions={openSessionsBoard}
/>
{/* Model/mode + config for the @mentioned bot(s); with no live
mention, fall back to the channel's bots so the controls are
always reachable. */}
{/* Model/mode + config for the target bot(s). A pinned session routes
to exactly one bot (its own), so narrow to that bot and show its
session's model; otherwise the @mentioned bots, falling back to the
channel's bots so the controls are always reachable. */}
<ComposerModelPopover
channelId={channel.channel_id}
bots={
mentionedBots.length > 0
? mentionedBots.map((m) => ({ botId: m.id, name: m.label }))
: switcherBots
selectedSessionId && selectedSessionBotId
? [
{
botId: selectedSessionBotId,
name:
switcherBots.find((b) => b.botId === selectedSessionBotId)?.name ??
selectedSessionBotId,
},
]
: mentionedBots.length > 0
? mentionedBots.map((m) => ({ botId: m.id, name: m.label }))
: switcherBots
}
selectedSessionId={selectedSessionId}
/>
</>
) : null,
// sendResourceReq and openSessionsBoard are identity-stable (useCallback).
[channel, switcherBots, selectedSessionId, mentionedBots, sendResourceReq, openSessionsBoard]
[channel, switcherBots, selectedSessionId, selectedSessionBotId, mentionedBots, sendResourceReq, openSessionsBoard]
);

// In-flight bot turns, for the composer's send→stop morph. The array identity
Expand Down Expand Up @@ -1291,6 +1379,9 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
replyTo={replyTo}
draftText={draftText}
files={channelFiles}
onBrowseWorkbench={browseWorkbench}
onBrowseWorkspace={browseWorkspace}
onJumpToSource={jumpToContextSource}
/>
)}

Expand Down
22 changes: 17 additions & 5 deletions frontend/src/features/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ interface Props {
selectedIds?: ReadonlySet<string>;
/** Jump request from outside (ViewBoard history items): scroll the message into
* view and flash it. `nonce` distinguishes repeat jumps to the same message.
* Best-effort — silently ignored when the message isn't in the loaded window. */
* The sender (ChannelView) backfills history first, so the target is loaded. */
focusMsg?: { msgId: string; nonce: number } | null;
}

Expand All @@ -64,21 +64,33 @@ export function MessageList({
const [highlightId, setHighlightId] = useState<string | null>(null);

// External jump (ViewBoard history rows): scroll to the anchored row + flash.
// Best-effort — if the message isn't in the loaded window there is no anchor
// and the jump is a silent no-op (lightweight by design).
// ChannelView backfills older pages before focusing, so by the time focusMsg
// lands the message is loaded — no anchor now means the row exists but isn't
// rendered (e.g. a resolved approval folded into the bot turn's trace).
useEffect(() => {
if (!focusMsg) return;
const el = containerRef.current?.querySelector(
`[data-msg-id="${CSS.escape(focusMsg.msgId)}"]`
);
if (!el) {
toast("Message isn't loaded — scroll up to load older history", { icon: "🔍" });
toast("This message isn't shown in the channel view", { icon: "🔍", id: "jump-hidden" });
return;
}
el.scrollIntoView({ block: "center", behavior: "smooth" });
setHighlightId(focusMsg.msgId);
// content-visibility rows above the target materialize their real heights
// during the smooth scroll (backfilled pages arrive with 80px estimates),
// drifting the anchor — one instant corrective pass after it settles.
const settle = setTimeout(() => {
containerRef.current
?.querySelector(`[data-msg-id="${CSS.escape(focusMsg.msgId)}"]`)
?.scrollIntoView({ block: "center" });
}, 700);
const t = setTimeout(() => setHighlightId(null), 1800);
return () => clearTimeout(t);
return () => {
clearTimeout(settle);
clearTimeout(t);
};
}, [focusMsg]);

// Resolved approvals are folded into each bot turn's trace, not shown as their own rows.
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/features/chat/RemoteWorkspaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
File,
FolderTree,
Download,
Paperclip,
MessageSquarePlus,
FileText,
Folder,
FolderPlus,
Expand Down Expand Up @@ -1823,7 +1823,7 @@ export function RemoteWorkspaceDialog({
)}
className="flex items-center gap-1 px-2 py-0.5 rounded hover:bg-zinc-800 text-zinc-300"
>
<Paperclip className="w-3 h-3" /> {attached ? ADDED_TO_CONTEXT : ADD_TO_CONTEXT}
<MessageSquarePlus className="w-3 h-3" /> {attached ? ADDED_TO_CONTEXT : ADD_TO_CONTEXT}
</button>
)}
</div>
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/features/chat/SessionChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export function SessionChip({
bots: SwitcherBot[];
/** "" = Auto (no session_id sent; mention routing → primary sessions). */
value: string;
onChange: (sessionId: string) => void;
/** `botId` is the session's owning bot (omitted for Auto) — lets the composer
* narrow the model chip to the single bot this pinned session targets. */
onChange: (sessionId: string, botId?: string) => void;
sendResourceReq: SendResourceReq;
/** Open the ViewBoard focused on the Sessions board. */
onManageSessions: () => void;
Expand Down Expand Up @@ -202,12 +204,12 @@ export function SessionChip({
function select(next: string) {
setOpen(false);
if (next === value) return;
onChange(next);
const t = next ? entries.find((s) => s.session_id === next) : undefined;
onChange(next, t?.bot_id);
if (!next) {
toast("Default routing restored (@mention → primary session)");
return;
}
const t = entries.find((s) => s.session_id === next);
if (t) toast.success(`Switched · messages will go directly to @${t.bot_name} (${tagOf(t)})`);
}

Expand Down Expand Up @@ -472,8 +474,8 @@ export function SessionChip({
// should go where they just created. The awaited load keeps the
// stale-target reset from bouncing the fresh id back to Auto.
void load().then((next) => {
if (next?.some((s) => s.session_id === created.session_id))
onChange(created.session_id);
const entry = next?.find((s) => s.session_id === created.session_id);
if (entry) onChange(created.session_id, entry.bot_id);
});
}}
/>
Expand Down
Loading
Loading