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),