From 54038e175c588092a498b8683c01d2f51800554e Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Thu, 1 Oct 2026 11:12:50 -0700 Subject: [PATCH] workplace: nothing is sent to a teammate before the conversation is found A message typed the instant a direct message opened, before the card list had arrived, opened a second conversation with the same teammate (seen on hosted, where the list lands later). The composer waits for the first list, and a send still looks once more for the existing conversation before opening a new one. Co-Authored-By: Claude Fable 5.1 --- kits/workplace/app/src/App.jsx | 7 ++++--- kits/workplace/app/src/components/DmView.jsx | 10 +++++++--- kits/workplace/app/src/lib/dm.js | 8 +++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/kits/workplace/app/src/App.jsx b/kits/workplace/app/src/App.jsx index 405e7fc..6384197 100644 --- a/kits/workplace/app/src/App.jsx +++ b/kits/workplace/app/src/App.jsx @@ -86,6 +86,7 @@ function Workplace() { const [bootErr, setBootErr] = useState(''); const [teammates, setTeammates] = useState(null); // null = loading const [cards, setCards] = useState([]); + const [cardsAt, setCardsAt] = useState(0); // when the list was first read: a direct message is found in it, so nothing is sent before const [docs, setDocs] = useState({}); // group session id -> document const docsAt = useRef({}); const [liveLocal, setLiveLocal] = useState({}); // teammate id -> true while this tab runs its turn @@ -108,7 +109,7 @@ function Workplace() { const harnessIds = useMemo(() => [rec?.id, ...(teammates || []).map((t) => t.id)].filter(Boolean), [rec, teammates]); const refreshCards = useCallback(async () => { if (!me || !harnessIds.length) return; - try { setCards(await listCards(me, harnessIds)); } catch { /* keep the last list */ } + try { setCards(await listCards(me, harnessIds)); setCardsAt((t) => t || Date.now()); } catch { /* keep the last list */ } }, [me, harnessIds]); const ready = !!rec && !!me; @@ -150,9 +151,9 @@ function Workplace() { const unread = useCallback((key, marker) => !!marker && seen[key] !== undefined && seen[key] !== marker, [seen]); const value = useMemo(() => ({ - route, navigate, rec, me, teammates, refreshTeammates, cards, refreshCards, groupCards, docs, setDoc, + route, navigate, rec, me, teammates, refreshTeammates, cards, cardsLoaded: cardsAt > 0, refreshCards, groupCards, docs, setDoc, working, markLive, markSeen, unread, openDrawer: () => setDrawer(true), closeDrawer: () => setDrawer(false), tabId: TAB_ID, - }), [route, rec, me, teammates, refreshTeammates, cards, refreshCards, groupCards, docs, setDoc, working, markLive, markSeen, unread]); + }), [route, rec, me, teammates, refreshTeammates, cards, cardsAt, refreshCards, groupCards, docs, setDoc, working, markLive, markSeen, unread]); useEffect(() => { setDrawer(false); }, [route.kind, route.id]); diff --git a/kits/workplace/app/src/components/DmView.jsx b/kits/workplace/app/src/components/DmView.jsx index 1f232c2..c2665aa 100644 --- a/kits/workplace/app/src/components/DmView.jsx +++ b/kits/workplace/app/src/components/DmView.jsx @@ -35,7 +35,7 @@ function turnFiles(t, sid) { export default function DmView({ teammate }) { const wp = useWorkplace(); - const { me, cards, teammates, navigate, markLive, markSeen, refreshCards, refreshTeammates, openDrawer } = wp; + const { me, cards, cardsLoaded, teammates, navigate, markLive, markSeen, refreshCards, refreshTeammates, openDrawer } = wp; const dialog = useDialog(); const card = findDm(cards, teammate.id, me); const [sid, setSid] = useState(card?.id || ''); @@ -60,7 +60,11 @@ export default function DmView({ teammate }) { useEffect(() => { if (!sid && card?.id) setSid(card.id); }, [card?.id, sid]); const externalBusy = (!!(card && isLive(card)) || !!extLive) && !live; - const busy = !!live || externalBusy; + // Nothing is sent before the card list has been read once: the direct message is found in that + // list, and a message sent before it arrived opened a second conversation with the same teammate + // (hosted, where the list lands later, 2026-10-01). + const finding = !sid && !cardsLoaded; + const busy = !!live || externalBusy || finding; const load = useCallback(async () => { if (!sid) { setTurns([]); return; } @@ -198,7 +202,7 @@ export default function DmView({ teammate }) { + hint={finding ? 'Opening your conversation…' : busy ? `${teammate.name} is working on your last message.` : ''} /> {files.pane} setRailOpen(false)} teammate={teammate} sessions={sid ? [sid] : []} title={teammate.name} diff --git a/kits/workplace/app/src/lib/dm.js b/kits/workplace/app/src/lib/dm.js index 446f2fc..e396c3c 100644 --- a/kits/workplace/app/src/lib/dm.js +++ b/kits/workplace/app/src/lib/dm.js @@ -4,7 +4,7 @@ import { patchSession, sessionTurns, streamTurn, turnsToMessages } from 'reifyui/harness'; import { DM_TITLE } from './kit.js'; import { mine } from './auth.js'; -import { turnInput } from './api.js'; +import { listCards, turnInput } from './api.js'; export const dmTitle = (me) => `${DM_TITLE} · ${me?.name || 'me'}`; @@ -31,6 +31,12 @@ export async function loadDm(sid) { * and not the finish; a follow-up turn renamed the session again). The name is how the direct * message is found next time, so it is put back when the turn is over. */ export async function sendDm({ sid, bot, me, text, files = [], handlers = {} }) { + // Before opening a new conversation, look once more for the existing one: the caller's list may + // be older than the conversation (another window opened it a moment ago). + if (!sid) { + const found = findDm(await listCards(me, [bot.id]).catch(() => []), bot.id, me); + if (found) { sid = found.id; handlers.onSession?.(sid); } + } let session = sid || ''; const r = await streamTurn({ sessionId: sid, harnessId: bot.id, input: turnInput(text, files), instructions: dmInstructions(bot, me),