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
12 changes: 7 additions & 5 deletions deploy/helm/cheers/templates/frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ data:
index index.html;

# Security headers (audit M2): clickjacking + MIME-sniff + referrer + a
# minimal CSP. CSP is intentionally narrow (no script-src lockdown) to
# avoid breaking the Vite SPA; tighten with nonces later. No location here
# defines its own add_header, so these inherit to every response.
# hardened CSP. The production Vite build emits only external hashed module
# scripts (no inline <script>), so script-src 'self' is safe; style-src keeps
# 'unsafe-inline' because the SPA injects runtime styles. API + WS are proxied
# same-origin (/api, /ws) so connect-src 'self' ws: wss:' covers them. No
# location here defines its own add_header, so these inherit to every response.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; object-src 'none'; base-uri 'self'" always;

location / {
try_files $uri $uri/ /index.html;
Expand All @@ -40,7 +42,7 @@ data:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "frame-ancestors 'none'; object-src 'none'; base-uri 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; object-src 'none'; base-uri 'self'" always;
}

location /api {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,11 @@ export default function App() {
<Route path="/reset" element={<ResetPasswordPage />} />
{/* Public: the landing page itself routes signed-out visitors to auth. */}
<Route path="/invite/:token" element={<InvitePage />} />
{/* The open workspace/channel live in the path so they survive a reload and
can be shared as a link; both are optional because /chat is the generic
entry point (ChatLayout then redirects to the personal workspace). */}
<Route
path="/chat/*"
path="/chat/:workspaceId?/:channelId?"
element={
<RequireAuth>
<ChatLayout />
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContext, useMemo } from "react";
import { memo, useContext, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { PathOpenContext, looksLikePath } from "@/features/chat/workspaceLink";
Expand Down Expand Up @@ -46,7 +46,7 @@ function CodeBlock({
);
}

export function MarkdownRenderer({ content, className }: Props) {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: Props) {
const onPath = useContext(PathOpenContext);
// react-markdown v10 dropped the `className` prop (passing it throws). Wrap in a styled
// div instead so "prose" + caller classes still apply.
Expand Down Expand Up @@ -92,4 +92,4 @@ export function MarkdownRenderer({ content, className }: Props) {
</ReactMarkdown>
</div>
);
}
});
28 changes: 27 additions & 1 deletion frontend/src/features/chat/ChannelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P

// Channel file index (id + name) built from loaded messages' attachments — no
// extra fetch. Powers F3 filename suggestions (draft names a file → offer it).
// Attachments never change during streaming, but `flushDeltas` swaps the whole
// `messages` array identity every rAF, so keying this off `messages` would rebuild
// the nested O(messages × attachments) index on every token delta. Instead key off a
// cheap signature of just the messages that HAVE files (id + count) — it only changes
// when attachments actually change, keeping both the work AND the value identity stable
// across deltas. (Cheap enough to compute inline each render.)
let channelFilesKey = `${messages.length}`;
for (const m of messages) {
const n = m.files?.length ?? 0;
if (n) channelFilesKey += `|${m.msg_id}:${n}`;
}
const channelFiles = useMemo(() => {
const byId = new Map<string, { file_id: string; filename: string }>();
for (const m of messages) {
Expand All @@ -158,7 +169,8 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
}
}
return Array.from(byId.values());
}, [messages]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelFilesKey]);

// A different channel means a different session set — drop any prior target.
// Also drop any buffered stream deltas + cancel a pending flush so a stale frame
Expand Down Expand Up @@ -579,6 +591,20 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P
setShowConnBanner(rtStatus === "offline");
}, [rtStatus]);

// If the realtime connection drops mid-stream, a bot bubble can be left with
// `_streaming: true` indefinitely (no done frame arrives), which the streaming
// fast-path renders as plain pre-wrap text. Finalize any lingering stream when we
// go offline so it falls back to full Markdown; reconnect catch-up then reconciles
// it with the persisted server state.
useEffect(() => {
if (rtStatus !== "offline") return;
setMessages((prev) =>
prev.some((m) => m._streaming)
? prev.map((m) => (m._streaming ? { ...m, _streaming: false } : m))
: prev
);
}, [rtStatus]);

// Re-flatten the palette when bot labels resolve after the initial fetch.
useEffect(() => {
void loadCommands();
Expand Down
65 changes: 62 additions & 3 deletions frontend/src/features/chat/ChatLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { listWorkspaces, getPersonalWorkspace } from "@/api/workspaces";
import { listChannels, listDms } from "@/api/channels";
import toast from "react-hot-toast";
Expand All @@ -24,10 +24,12 @@ export default function ChatLayout() {
setPersonalWorkspace,
setChannels,
selectWorkspace,
hydrateSelection,
} = useChatStore();
const isMobile = useIsMobile();
const location = useLocation();
const navigate = useNavigate();
const { workspaceId: urlWorkspaceId, channelId: urlChannelId } = useParams();

// Notification center bootstrap: hydrate the inbox once, then keep it live over a
// user-scoped socket (invites arrive even with no channel open). Kept here because
Expand All @@ -52,6 +54,52 @@ export default function ChatLayout() {
// Mobile-only workspace/nav drawer (the desktop rail, slid in from the left).
const [navOpen, setNavOpen] = useState(false);

// ── URL ⇄ store selection sync ───────────────────────────────────────────────
// The path owns the open workspace/channel: it's what survives a reload and what a
// shared link carries. Every in-app selection still goes through the store (rail,
// sidebar, dialogs), so the two are kept in step by a pair of effects. They can't
// ping-pong: the first only runs when the *path* changed (its deps are the params),
// the second only when the store disagrees with the path, and applying either makes
// them agree.
//
// appliedPathRef records the last selection we reconciled, so a path we wrote
// ourselves doesn't bounce back as an incoming change and undo a fresh selection.
const appliedPathRef = useRef<string | null>(null);

// Path → store: on mount, and on browser back/forward.
useEffect(() => {
const key = `${urlWorkspaceId ?? ""}/${urlChannelId ?? ""}`;
if (appliedPathRef.current === key) return;
appliedPathRef.current = key;
// Bare /chat names no selection — leave the store alone and let the bootstrap
// below pick the default, rather than blanking an already-good selection (this
// is the path InvitePage/RegisterPage arrive on after joining a workspace).
if (!urlWorkspaceId) return;
hydrateSelection(urlWorkspaceId, urlChannelId ?? null);
}, [urlWorkspaceId, urlChannelId, hydrateSelection]);

// Store → path: mirror any in-app selection back out.
useEffect(() => {
if (!selectedWorkspaceId) return;
const want = `/chat/${selectedWorkspaceId}${
selectedChannelId ? `/${selectedChannelId}` : ""
}`;
if (want === location.pathname) return;
appliedPathRef.current = `${selectedWorkspaceId}/${selectedChannelId ?? ""}`;
// replace, not push: on mobile the conversation screen is itself a pushed history
// entry (see openChatScreen), so a second entry per channel switch would make Back
// land on the previous channel instead of popping to the channel list. `state` is
// carried through for the same reason — dropping it would strip that push flag and
// bounce the user out of the conversation they just opened.
navigate(want, { replace: true, state: location.state });
}, [
selectedWorkspaceId,
selectedChannelId,
location.pathname,
location.state,
navigate,
]);

// Load workspaces + the personal workspace on mount. The personal workspace is the
// user's home (DMs + private space), so it's the default selection. On failure we
// raise bootstrapFailed and show a retry panel in the main area instead of the
Expand All @@ -63,7 +111,18 @@ export default function ChatLayout() {
.then(([ws, personal]) => {
setWorkspaces(ws);
if (personal) setPersonalWorkspace(personal);
if (!selectedWorkspaceId) {
// Read the selection fresh instead of through this callback's closure: the
// path hydrates the store while this request is in flight, so a captured
// `null` here would overwrite the workspace the URL just restored.
const current = useChatStore.getState().selectedWorkspaceId;
const known =
!!current &&
(current === personal?.workspace_id ||
ws.some((w) => w.workspace_id === current));
// Fall back to the home workspace when nothing is selected, or when the path
// named one this account can't see (a stale link, or someone else's). Without
// the membership check the rail would sit on a workspace that renders empty.
if (!known) {
selectWorkspace(personal?.workspace_id ?? ws[0]?.workspace_id ?? null);
}
setBootstrapFailed(false);
Expand All @@ -74,7 +133,7 @@ export default function ChatLayout() {
"Couldn't load your workspaces — check the gateway connection, then retry."
);
});
}, [selectedWorkspaceId, setWorkspaces, setPersonalWorkspace, selectWorkspace]);
}, [setWorkspaces, setPersonalWorkspace, selectWorkspace]);
useEffect(() => {
loadWorkspaces();
// Run once on mount; the Retry button re-invokes loadWorkspaces directly.
Expand Down
20 changes: 13 additions & 7 deletions frontend/src/features/chat/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,14 +472,20 @@ function MessageBody({
return <p className="text-sm text-red-400 italic">{message.error}</p>;
}

// While a bubble is streaming, re-parsing the whole accumulated Markdown +
// re-highlighting the growing code block every animation frame is ~O(len^2)
// main-thread work. Render the in-flight text as plain whitespace-pre-wrap and
// only switch to full Markdown + highlighting once the turn finalizes
// (_streaming clears), which leaves completed messages rendered exactly as before.
const hasMarkdown =
content.includes("```") ||
content.includes("**") ||
content.includes("*") ||
content.includes("#") ||
content.includes("[") ||
content.includes("\n") ||
content.includes("`");
!message._streaming &&
(content.includes("```") ||
content.includes("**") ||
content.includes("*") ||
content.includes("#") ||
content.includes("[") ||
content.includes("\n") ||
content.includes("`"));

return (
<div className="relative">
Expand Down
28 changes: 26 additions & 2 deletions frontend/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ interface ChatState {
patchChannel: (id: string, patch: Partial<Channel>) => void;
/** Add a channel if absent, else patch it (used when a DM is found-or-created). */
upsertChannel: (ch: Channel) => void;
/**
* Apply an explicit workspace+channel pair. Used to seed the store from the URL,
* which owns the selection across reloads and shared links (see ChatLayout).
* Unlike selectWorkspace this never derives the channel from
* lastChannelByWorkspace — the URL already names one, and deriving would clobber
* it — but it does refresh that memory, so a later A→B→A switch still restores
* whatever the URL opened.
*/
hydrateSelection: (workspaceId: string | null, channelId: string | null) => void;
}

export const useChatStore = create<ChatState>()(
Expand Down Expand Up @@ -71,12 +80,27 @@ export const useChatStore = create<ChatState>()(
? { channels: s.channels.map((c) => (c.channel_id === ch.channel_id ? { ...c, ...ch } : c)) }
: { channels: [...s.channels, ch] }
),
hydrateSelection: (workspaceId, channelId) =>
set((s) => {
const map = { ...s.lastChannelByWorkspace };
if (workspaceId) {
if (channelId) map[workspaceId] = channelId;
else delete map[workspaceId];
}
return {
selectedWorkspaceId: workspaceId,
selectedChannelId: channelId,
lastChannelByWorkspace: map,
};
}),
}),
{
name: "cheers.chat.selection",
// Only the cross-session channel memory is persisted. Workspaces/channels are
// server data refetched on mount, and the live selection is derived on load
// (ChatLayout selects the personal workspace, which restores its last channel).
// server data refetched on mount, and the live selection is restored from the
// URL (/chat/:workspaceId/:channelId), which is its source of truth across
// reloads — persisting it here too would give a shared link two competing
// answers for what to open.
partialize: (s) => ({ lastChannelByWorkspace: s.lastChannelByWorkspace }),
}
)
Expand Down
18 changes: 13 additions & 5 deletions packages/cheers-acp-connector-rs/src/acp_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -979,23 +979,31 @@ async fn handle_peer_message(
async fn handle_peer_notification(
account_id: &str,
event_tx: &mpsc::Sender<RuntimeEvent>,
value: Value,
mut value: Value,
) -> anyhow::Result<()> {
let method = value.get("method").and_then(Value::as_str).unwrap_or("");
if method != "session/update" {
tracing::debug!(account = %account_id, method, "ACP peer notification (not session/update; ignored)");
return Ok(());
}
let params = value.get("params").cloned().unwrap_or(Value::Null);
let acp_session_id = params
.get("sessionId")
// Read only the small sessionId out of the borrowed value...
let acp_session_id = value
.get("params")
.and_then(|params| params.get("sessionId"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if acp_session_id.is_empty() {
return Ok(());
}
let update = params.get("update").cloned().unwrap_or(Value::Null);
// ...then MOVE the (potentially large) update subtree out of the owned value
// instead of deep-cloning it — session/update is the highest-frequency event
// and tool_call_update carries the bulky tool output.
let update = value
.get_mut("params")
.and_then(|params| params.get_mut("update"))
.map(Value::take)
.unwrap_or(Value::Null);
tracing::debug!(
account = %account_id,
session = %acp_session_id,
Expand Down
10 changes: 8 additions & 2 deletions packages/cheers-acp-connector-rs/src/bridge_runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1967,8 +1967,14 @@ impl RuntimeContext {
)
.await?;
let (final_text, file_ids) = {
let guard = run.lock().await;
(guard.text.clone(), guard.created_file_ids.clone())
let mut guard = run.lock().await;
// Move the accumulated text out (turn is over; the run is about
// to be dropped from the shared maps below) so the whole streamed
// response isn't deep-cloned just to hand it to the Done frame.
(
std::mem::take(&mut guard.text),
guard.created_file_ids.clone(),
)
};
let terminal_ack = self
.io
Expand Down
23 changes: 23 additions & 0 deletions packages/cheers-acp-connector-rs/src/loopback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ use uuid::Uuid;
/// a pooled-but-idle MCP socket can't leak a task/fd for the life of the session.
const LOOPBACK_IDLE_TIMEOUT: Duration = Duration::from_secs(120);

/// Hard ceiling on a single loopback request body. The only legitimate client is
/// the local MCP bridge, whose resource calls are small JSON payloads, so a few
/// MiB is generous. Without this cap a local process could set an arbitrary
/// `Content-Length` and force the connection task to buffer that many bytes on the
/// heap before the token is even checked.
const MAX_LOOPBACK_BODY_BYTES: usize = 8 * 1024 * 1024;

#[derive(Debug, Clone)]
pub struct LoopbackHandle {
pub url: String,
Expand Down Expand Up @@ -233,6 +240,21 @@ async fn read_http_request(
.get("content-length")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
// Reject an oversized body from the declared Content-Length BEFORE draining it,
// so a local process can't force us to buffer an arbitrary amount of heap. We
// still write a proper 413 and then close (returning Err drops the connection).
if content_length > MAX_LOOPBACK_BODY_BYTES {
write_http_json(
stream,
413,
false,
&json!({ "ok": false, "code": "PAYLOAD_TOO_LARGE", "error": "loopback request body too large" }),
)
.await?;
return Err(anyhow!(
"loopback request body too large: {content_length} bytes (max {MAX_LOOPBACK_BODY_BYTES})"
));
}
let body_start = header_end + 4;
let body_end = body_start + content_length;
while buf.len() < body_end {
Expand Down Expand Up @@ -280,6 +302,7 @@ async fn write_http_json(
200 => "OK",
401 => "Unauthorized",
405 => "Method Not Allowed",
413 => "Payload Too Large",
_ => "Error",
};
// Content-Length is always present, so the client can frame the body and reuse
Expand Down
Loading
Loading