Replace the composer textarea with a Tiptap markdown editor

The composer now writes markdown with bold, italic, strike, code,
links, quotes and lists, from shortcuts, markdown typing or a bubble
over the selection. @ mentions a bot and # a workspace, shown as chips
and saved as [bot:ID] / [workspace:ID]. Text selected in a message can
be quoted into the composer. User messages render as markdown, and
replies tell the bot who and what the user mentioned.

Adds an ESM stand-in for use-sync-external-store, since bunext keeps
React external in page bundles and the CommonJS original requires it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 05:48:23 +01:00
co-authored by Claude Opus 5.5
parent e2093b0df6
commit 1208eac6b7
51 changed files with 1618 additions and 126 deletions
@@ -0,0 +1,69 @@
/**
* `useSyncExternalStoreWithSelector` from React's use-sync-external-store
* (MIT, Meta Platforms, Inc. and affiliates), as an ES module: it reads a
* store through a selector and only re-renders when the selection
* changes (`isEqual`).
*/
import { useDebugValue, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
export function useSyncExternalStoreWithSelector(subscribe, getSnapshot, getServerSnapshot, selector, isEqual) {
const instRef = useRef(null);
if (instRef.current === null) instRef.current = { hasValue: false, value: null };
const inst = instRef.current;
const [getSelection, getServerSelection] = useMemo(() => {
let hasMemo = false;
let memoizedSnapshot;
let memoizedSelection;
const memoizedSelector = (nextSnapshot) => {
if (!hasMemo) {
hasMemo = true;
memoizedSnapshot = nextSnapshot;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined && inst.hasValue && isEqual(inst.value, nextSelection)) {
memoizedSelection = inst.value;
return memoizedSelection;
}
memoizedSelection = nextSelection;
return nextSelection;
}
const currentSelection = memoizedSelection;
if (Object.is(memoizedSnapshot, nextSnapshot)) return currentSelection;
const nextSelection = selector(nextSnapshot);
if (isEqual !== undefined && isEqual(currentSelection, nextSelection)) {
memoizedSnapshot = nextSnapshot;
return currentSelection;
}
memoizedSnapshot = nextSnapshot;
memoizedSelection = nextSelection;
return nextSelection;
};
return [
() => memoizedSelector(getSnapshot()),
getServerSnapshot === undefined ? undefined : () => memoizedSelector(getServerSnapshot()),
];
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
const value = useSyncExternalStore(subscribe, getSelection, getServerSelection);
useEffect(() => {
inst.hasValue = true;
inst.value = value;
}, [value]);
useDebugValue(value);
return value;
}