diff --git a/docs/api.md b/docs/api.md index 9d04db6c3..24677df0b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -259,7 +259,7 @@ A human's `agent_key` on `POST /me/conversations` must already be a normalised a **Provenance caveat (TASK-4039 / TASK-4041):** `X-Pad-Agent` is self-declared attribution, not authentication. A holder of the owner's credential can choose another agent label within that owner's namespace, or omit it and post as the human, also avoiding the agent send bucket (the general API limit still applies). An owner-authored message is therefore **not authenticated as human input**. Stage 2a accepts this because both conversation members represent the same owner and credential authority; it must not be presented as a verified human channel. Owner-authored messages are framed as information by default (TASK-4041); `pad chat arm --accept-instructions` opts this session into an unauthenticated channel controlled by the agent's own credential holder. The framing rules are under `GET /api/v1/events/stream` above. A verifiable human channel would require a credential the agent cannot hold. -Wire types are explicit projections; `owner_user_id`, `dm_key`, `author_user_id`, raw `context_workspace_id` and `op_key` never appear in responses. `Conv` is `{id, number, kind, title, agent_key, last_seq, created_at, updated_at}`. `ConvDetail` adds `{read_seq, unread}`. The single-conversation GET additionally includes the caller's own `delivered_seq`; inbox rows omit it. `ConvSummary` adds optional `last_message: {seq, author_agent_key, author_is_human, excerpt, deleted, created_at}` to the detail; it never includes a full body. `Msg` is `{id, conversation_id, seq, author_agent_key, author_is_human, mine, source, body, reply_to_seq?, reply_to?, context_workspace?: {slug, name}, created_at, deleted, deleted_at?}`; `mine` compares both components of the author and actor pair. `reply_to` is `{seq, author_agent_key, author_is_human, excerpt, deleted}`, the store's bounded quote preview. +Wire types are explicit projections; `owner_user_id`, `dm_key`, `author_user_id`, raw `context_workspace_id` and `op_key` never appear in responses. `Conv` is `{id, number, kind, title, agent_key, last_seq, created_at, updated_at}`. `kind` is `"direct"` or `"group"`; stage 2a creates only `"direct"`. `ConvDetail` adds `{read_seq, unread}`. The single-conversation GET additionally includes the caller's own `delivered_seq`; inbox rows omit it. `ConvSummary` adds optional `last_message: {seq, author_agent_key, author_is_human, excerpt, deleted, created_at}` to the detail; it never includes a full body. `Msg` is `{id, conversation_id, seq, author_agent_key, author_is_human, mine, source, body, reply_to_seq?, reply_to?, context_workspace?: {slug, name}, created_at, deleted, deleted_at?}`; `mine` compares both components of the author and actor pair. `reply_to` is `{seq, author_agent_key, author_is_human, excerpt, deleted}`, the store's bounded quote preview. Paths in the following table omit `/api/v1`. `{n}` is the owner's positive conversation number; `{seq}` is a positive message sequence. Every route also has the admission errors above and generic `500 internal_error` on unexpected failures. diff --git a/web/e2e/chat-roster-dm-link.spec.ts b/web/e2e/chat-roster-dm-link.spec.ts new file mode 100644 index 000000000..eee42a748 --- /dev/null +++ b/web/e2e/chat-roster-dm-link.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from './fixtures'; + +/** + * TASK-4044 (ruling 140d6e04): the /messages roster keeps a conversation as a + * DM by its `kind`. The server writes 'direct' (migrations/158_conversations.sql + * CHECK kind IN ('direct','group')); a roster filter on any other literal drops + * every conversation, and unit fixtures cannot see that because they build their + * own kind. This spec reads the server's real value: a DM created through the + * API must show up in the roster as a link to /messages/. + */ +test('the roster shows an agent with a DM as a link to its conversation', async ({ + page, + request, + fixture +}) => { + const agent = `rosterlink-${Date.now().toString(36)}`; + const res = await request.post('/api/v1/me/conversations', { + headers: { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' }, + data: { agent_key: agent } + }); + expect(res.ok(), await res.text()).toBe(true); + const { conversation } = (await res.json()) as { conversation: { number: number } }; + expect(conversation.number).toBeGreaterThan(0); + + await page.goto('/messages'); + + const row = page + .getByRole('list', { name: 'Agents' }) + .locator('a.row') + .filter({ has: page.locator('.name', { hasText: agent }) }); + await expect(row).toHaveCount(1); + await expect(row).toHaveAttribute('href', `/messages/${conversation.number}`); +}); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 7aaa8969d..c60c42a5c 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -85,6 +85,7 @@ import type { AttachmentTransformResult, ServerCapabilities, LiveSessionsResponse, + ChatConversation, ChatConversationSummary, ChatConversationsResponse, ChatUnreadResponse, @@ -2440,6 +2441,12 @@ export const api = { chat: { listConversations: () => request('/me/conversations'), + /** Create-or-reuse the DM with `agent_key`; 201 when created, 200 when reused. */ + ensureConversation: (agent_key: string) => + request<{ conversation: ChatConversation; created: boolean }>('/me/conversations', { + method: 'POST', + body: JSON.stringify({ agent_key }) + }), getConversation: (n: number) => request(`/me/conversations/${n}`), diff --git a/web/src/lib/components/chat/ConversationList.svelte.test.ts b/web/src/lib/components/chat/ConversationList.svelte.test.ts index 3e4592ad1..6d3d0341a 100644 --- a/web/src/lib/components/chat/ConversationList.svelte.test.ts +++ b/web/src/lib/components/chat/ConversationList.svelte.test.ts @@ -20,7 +20,7 @@ function conv(n: number, over: Partial = {}): ChatConve return { id: `c${n}`, number: n, - kind: 'dm', + kind: 'direct', title: '', agent_key: `agent-${n}`, last_seq: 1, diff --git a/web/src/lib/components/chat/RosterRow.svelte b/web/src/lib/components/chat/RosterRow.svelte new file mode 100644 index 000000000..516906a27 --- /dev/null +++ b/web/src/lib/components/chat/RosterRow.svelte @@ -0,0 +1,88 @@ + + +{#if entry.number !== null} + + + {entry.key} + + +{:else} + + {#if rateLimited} +

Too many new chats right now. Try again in a few seconds.

+ {:else if failed} +

Couldn't start the chat. Try again.

+ {/if} +{/if} + + diff --git a/web/src/lib/eachKeyCensus.ts b/web/src/lib/eachKeyCensus.ts index f9311520d..40d6562a0 100644 --- a/web/src/lib/eachKeyCensus.ts +++ b/web/src/lib/eachKeyCensus.ts @@ -23,6 +23,7 @@ export const EACH_KEY_REASONS = { export type EachKeyReason = keyof typeof EACH_KEY_REASONS; export const EACH_KEY_CENSUS: Record = { + "routes/messages/+page.svelte :: each chatInbox.roster as entry (entry.key)": { reason: 'object-or-map-keys', note: "chatInbox.roster is built by iterating the `presences` Map (rollupPresence), keyed by agent name | Map keys cannot repeat." }, "lib/components/BacklinksPanel.svelte :: each group.rows as bl, blIdx (rowKey(bl, blIdx))": { reason: 'position-or-counter', note: "rowKey ends with the row's index" }, "lib/components/BacklinksPanel.svelte :: each groups as group (group.key)": { reason: 'object-or-map-keys', note: "SvelteMap keyed by `ws::collection` (l.121) | Map keys." }, "lib/components/ChildChart.svelte :: each chartData.yTicks as tick (tick.value)": { reason: 'strictly-increasing', note: "local loop (l.132-139) | `v` rises strictly by `step >= 1`; `total` is pushed only if it is not already last." }, diff --git a/web/src/lib/stores/chatInbox.svelte.test.ts b/web/src/lib/stores/chatInbox.svelte.test.ts index 4e2895fb0..0fa0f04f3 100644 --- a/web/src/lib/stores/chatInbox.svelte.test.ts +++ b/web/src/lib/stores/chatInbox.svelte.test.ts @@ -8,11 +8,14 @@ const h = vi.hoisted(() => ({ unread: vi.fn(), list: vi.fn(), sessions: vi.fn(), + ensure: vi.fn(), state: { authenticated: true, epoch: 0, listeners: [] as Array<(prev: string) => void> } })); vi.mock('$lib/api/client', () => ({ - api: { chat: { unread: h.unread, listConversations: h.list }, sessions: { list: h.sessions } } + api: { + chat: { unread: h.unread, listConversations: h.list, ensureConversation: h.ensure }, + sessions: { list: h.sessions } } })); vi.mock('$lib/stores/auth.svelte', () => ({ authStore: { @@ -31,7 +34,7 @@ vi.mock('$lib/stores/auth.svelte', () => ({ })); import { CHAT_HINT_DEBOUNCE_MS, CHAT_RETRY_MS } from './chat.svelte'; -import { PRESENCE_POLL_MS, PRESENCE_STALE_MS } from './chatInbox.svelte'; +import { ENSURE_COOLDOWN_MS, PRESENCE_POLL_MS, PRESENCE_STALE_MS } from './chatInbox.svelte'; type Chat = (typeof import('./chat.svelte'))['chatStore']; type Inbox = (typeof import('./chatInbox.svelte'))['chatInbox']; @@ -41,7 +44,7 @@ let inbox: Inbox; const conv = (n: number) => ({ id: `c${n}`, number: n, - kind: 'dm', + kind: 'direct', title: '', agent_key: `a${n}`, last_seq: 1, @@ -87,6 +90,7 @@ beforeEach(async () => { h.unread.mockReset(); h.list.mockReset(); h.sessions.mockReset(); + h.ensure.mockReset(); h.sessions.mockResolvedValue({ sessions: [], count: 0 }); setVisibility('visible'); h.unread.mockResolvedValue({ conversations: [], total: 0 }); @@ -98,10 +102,21 @@ beforeEach(async () => { }); afterEach(() => { + // The list and the unread store hold their own timers: end them first, so + // the only timer left standing can be a presence poll. inbox.stop(); - inbox.stopPresence(); chat.stop(); - vi.useRealTimers(); + try { + // startPresence is reference counted. Every test releases the owners it took + // (the describes that start presence do it in their own afterEach, which runs + // before this one), so an armed presence poll here is a leaked owner. + expect(vi.getTimerCount(), 'a test left a presence owner started').toBe(0); + } finally { + // One drain for safety only; the assertion above ran first. Its failure must + // not leave the fake clock behind for the next test. + inbox.stopPresence(); + vi.useRealTimers(); + } }); describe('chatInbox (TASK-4044)', () => { @@ -265,6 +280,11 @@ describe('chatInbox (TASK-4044)', () => { }); describe('chatInbox presence (TASK-4044 2b-i)', () => { + afterEach(() => { + // These tests start one presence owner each and do not stop it themselves. + inbox.stopPresence(); + }); + it('startPresence fires no list fetch', async () => { inbox.startPresence(); await flush(); @@ -317,6 +337,8 @@ describe('chatInbox presence (TASK-4044 2b-i)', () => { expect(h.sessions).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(PRESENCE_POLL_MS); expect(h.sessions).toHaveBeenCalledTimes(2); + // Two owners were taken; the describe's afterEach releases one. + inbox.stopPresence(); }); it('reads online from a matching session, offline only after a successful read without one', async () => { @@ -464,3 +486,228 @@ describe('chatInbox presence (TASK-4044 2b-i)', () => { expect(inbox.presences.get('a2')).toBe('offline'); }); }); + +// TASK-4044 2b-ii: the roster and ensureDM (ruling 018faf64). +const ensureOk = (n: number, created = true) => ({ conversation: conv(n), created }); +const rateErr = (retryAfterMs?: number) => + Object.assign(new Error('rate_limited'), { code: 'rate_limited', retryAfterMs }); +const rosterKeys = () => inbox.roster.map((r) => r.key); + +describe('chatInbox roster (TASK-4044 2b-ii)', () => { + afterEach(() => { + // These tests start one presence owner each and do not stop it themselves. + inbox.stopPresence(); + }); + + async function loadWith(convs: ReturnType[], sessions: ReturnType[] | 'down') { + h.list.mockResolvedValue({ conversations: convs, unread_total: 0 }); + if (sessions === 'down') h.sessions.mockRejectedValue(apiErr('unavailable')); + else h.sessions.mockResolvedValue(sessResp(...sessions)); + inbox.start(); + inbox.startPresence(); + await flush(); + } + + it('orders online, busy, connected-not-accepting, unknown, offline, ties by name', async () => { + const named = (n: number, key: string) => ({ ...conv(n), agent_key: key }); + await loadWith( + [named(1, 'zed'), named(2, 'amy'), named(3, 'bob'), named(4, 'cat'), named(5, 'dan'), named(6, 'eve')], + [ + sess('zed', 'online'), + sess('amy', 'online'), + sess('bob', 'busy'), + sess('cat', 'connected_not_accepting') + ] + ); + expect(rosterKeys()).toEqual(['amy', 'zed', 'bob', 'cat', 'dan', 'eve']); + expect(inbox.roster.map((r) => r.presence)).toEqual([ + 'online', + 'online', + 'busy_uncertain', + 'connected_not_accepting', + 'offline', + 'offline' + ]); + }); + + it('carries the DM number, and null for a session-only agent', async () => { + await loadWith([conv(1)], [sess('ghost'), sess('a1')]); + expect(inbox.roster).toEqual([ + { key: 'a1', presence: 'online', number: 1 }, + { key: 'ghost', presence: 'online', number: null } + ]); + }); + + // Row 15: unread belongs to chatStore alone. + it('carries no unread count', async () => { + await loadWith([{ ...conv(1), unread: 4 }], []); + for (const r of inbox.roster) expect(Object.keys(r).sort()).toEqual(['key', 'number', 'presence']); + }); + + // Row 16 / Q-e: while sessions cannot be read, only DM agents are listed, as unknown. + it('shows session-only agents after a good read and drops them when unavailable', async () => { + await loadWith([conv(1)], [sess('ghost')]); + expect(rosterKeys()).toContain('ghost'); + h.sessions.mockRejectedValue(apiErr('unavailable')); + await vi.advanceTimersByTimeAsync(PRESENCE_POLL_MS); + await flush(); + expect(rosterKeys()).toEqual(['a1']); + expect(inbox.roster[0].presence).toBe('unknown'); + }); + + it('before the first read the roster is the DM agents as unknown', async () => { + h.sessions.mockReturnValue(new Promise(() => {})); + h.list.mockResolvedValue(listResp(1)); + inbox.start(); + inbox.startPresence(); + await flush(); + expect(inbox.roster).toEqual([{ key: 'a1', presence: 'unknown', number: 1 }]); + }); + + // Q3 + it("a group conversation (kind 'group') is not on the roster", async () => { + await loadWith([conv(1), { ...conv(2), kind: 'group', agent_key: 'notadm' }], []); + expect(rosterKeys()).toEqual(['a1']); + }); +}); + +describe('chatInbox ensureDM (TASK-4044 2b-ii)', () => { + it('returns the number for a created (201) and a reused (200) DM, and reloads each time', async () => { + h.ensure.mockResolvedValueOnce(ensureOk(7, true)).mockResolvedValueOnce(ensureOk(7, false)); + expect(await inbox.ensureDM('amy')).toBe(7); + expect(await inbox.ensureDM('amy')).toBe(7); + expect(h.ensure).toHaveBeenCalledTimes(2); + expect(h.list).toHaveBeenCalledTimes(2); + expect(inbox.ensuring.size).toBe(0); + }); + + // Row 7 + it('a double click fires one POST', async () => { + let resolve!: (v: unknown) => void; + h.ensure.mockReturnValue(new Promise((r) => (resolve = r))); + const first = inbox.ensureDM('amy'); + const second = await inbox.ensureDM('amy'); + expect(second).toBeNull(); + expect(h.ensure).toHaveBeenCalledTimes(1); + resolve(ensureOk(7)); + expect(await first).toBe(7); + }); + + it('an empty key posts nothing', async () => { + expect(await inbox.ensureDM(' ')).toBeNull(); + expect(h.ensure).not.toHaveBeenCalled(); + }); + + // Row 8 + it('a 429 is one POST, never retried, and flagged', async () => { + h.ensure.mockRejectedValue(rateErr()); + expect(await inbox.ensureDM('amy')).toBeNull(); + expect(inbox.rateLimited['amy']).toBe(true); + await vi.advanceTimersByTimeAsync(60_000); + await flush(); + expect(h.ensure).toHaveBeenCalledTimes(1); + }); + + // Row 9 + it.each([ + [undefined, ENSURE_COOLDOWN_MS], + [1000, ENSURE_COOLDOWN_MS], + [9000, 9000] + ])('after a 429 with retryAfterMs=%s the key stays off until %dms', async (retryAfterMs, wait) => { + h.ensure.mockRejectedValue(rateErr(retryAfterMs)); + await inbox.ensureDM('amy'); + await vi.advanceTimersByTimeAsync(wait - 1); + expect(inbox.rateLimited['amy']).toBe(true); + expect(await inbox.ensureDM('amy')).toBeNull(); + expect(h.ensure).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(inbox.rateLimited['amy']).toBeUndefined(); + }); + + // Row 9b + it('another failure is flagged and allows a retry at once', async () => { + h.ensure.mockRejectedValueOnce(new Error('500')).mockResolvedValueOnce(ensureOk(7)); + expect(await inbox.ensureDM('amy')).toBeNull(); + expect(inbox.ensureFailed['amy']).toBe(true); + expect(inbox.rateLimited['amy']).toBeUndefined(); + expect(inbox.ensuring.size).toBe(0); + expect(await inbox.ensureDM('amy')).toBe(7); + expect(inbox.ensureFailed['amy']).toBeUndefined(); + }); + + // K1 + it('an identity change while the POST is in flight fences it: no load, no number, ensuring cleared', async () => { + let resolve!: (v: unknown) => void; + h.ensure.mockReturnValue(new Promise((r) => (resolve = r))); + const p = inbox.ensureDM('amy'); + await flush(); + expect(inbox.ensuring.has('amy')).toBe(true); + changeIdentity(); + expect(inbox.ensuring.size).toBe(0); + resolve(ensureOk(7)); + expect(await p).toBeNull(); + expect(h.list).not.toHaveBeenCalled(); + expect(inbox.ensuring.size).toBe(0); + }); + + it('an identity change while the reload runs also yields null', async () => { + let resolveList!: (v: unknown) => void; + h.ensure.mockResolvedValue(ensureOk(7)); + h.list.mockReturnValue(new Promise((r) => (resolveList = r))); + const p = inbox.ensureDM('amy'); + await flush(); + changeIdentity(); + resolveList(listResp(7)); + expect(await p).toBeNull(); + }); + + // K2 + it('an identity change in the cool-down clears the flag and its timer', async () => { + h.ensure.mockRejectedValue(rateErr()); + await inbox.ensureDM('amy'); + expect(inbox.rateLimited['amy']).toBe(true); + const timers = vi.getTimerCount(); + changeIdentity(); + expect(inbox.rateLimited['amy']).toBeUndefined(); + expect(vi.getTimerCount()).toBe(timers - 1); + h.ensure.mockResolvedValue(ensureOk(7)); + expect(await inbox.ensureDM('amy')).toBe(7); + }); +}); + +describe('chatInbox presence ref count (TASK-4044 2b-ii)', () => { + const seen = () => h.sessions.mock.calls.length; + + it.each([ + ['start before stop', true], + ['stop before start', false] + ])('overlapping owners, %s: one timer, one poll per tick, 0 after the last stop', async (_n, startFirst) => { + inbox.startPresence(); + await flush(); + expect(vi.getTimerCount()).toBe(1); + if (startFirst) { + inbox.startPresence(); + inbox.stopPresence(); + } else { + inbox.stopPresence(); + inbox.startPresence(); + } + await flush(); + expect(vi.getTimerCount()).toBe(1); + const before = seen(); + await vi.advanceTimersByTimeAsync(PRESENCE_POLL_MS); + expect(seen() - before).toBe(1); + inbox.stopPresence(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('a stop with no owner is a no-op and does not go negative', async () => { + inbox.stopPresence(); + inbox.stopPresence(); + inbox.startPresence(); + await flush(); + expect(vi.getTimerCount()).toBe(1); + inbox.stopPresence(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/web/src/lib/stores/chatInbox.svelte.ts b/web/src/lib/stores/chatInbox.svelte.ts index 58ce392d8..80575e54e 100644 --- a/web/src/lib/stores/chatInbox.svelte.ts +++ b/web/src/lib/stores/chatInbox.svelte.ts @@ -12,6 +12,9 @@ // // Presence (TASK-4044 2b-i): startPresence() polls GET /sessions on its own, apart // from start(), so a page that only wants a presence dot fires no list fetch. +// +// Roster and ensure (TASK-4044 2b-ii, ruling 018faf64): roster merges presences +// with the DM numbers; ensureDM() creates-or-reuses a DM and is fenced by identity. import { untrack } from 'svelte'; import { api } from '$lib/api/client'; import { authStore } from '$lib/stores/auth.svelte'; @@ -19,8 +22,25 @@ import { chatStore } from '$lib/stores/chat.svelte'; import { normalizeAgentName, rollupPresence, type AgentPresence } from '$lib/chat/presence'; import type { ChatConversationSummary, LiveSession } from '$lib/types'; +/** One roster row: an agent, how reachable it is, and its DM number if it has one. */ +export interface RosterEntry { + key: string; + presence: AgentPresence; + number: number | null; +} + +const ROSTER_RANK: Record = { + online: 0, + busy_uncertain: 1, + connected_not_accepting: 2, + unknown: 3, + offline: 4 +}; + /** How often the sessions list is read while the tab is visible. */ export const PRESENCE_POLL_MS = 10_000; +/** How long a rate-limited ensure keeps its button off, at the least. */ +export const ENSURE_COOLDOWN_MS = 5_000; /** How long a transient failure may keep the last good read before it reads as unknown. */ export const PRESENCE_STALE_MS = 30_000; @@ -39,20 +59,50 @@ class ChatInboxStore { checkedAt = $state(null); /** - * Presence per agent key over the current conversations, for the roster that - * TASK-4044 2b-ii adds. It has no consumer in 2b-i. + * Presence per agent key over the current DMs (a group conversation's key is + * not an agent to chat with, ruling 018faf64 Q3), plus session-only agents + * while the sessions read is good. It feeds the roster. */ presences = $derived( rollupPresence( - this.conversations.map((c) => c.agent_key), + this.conversations.filter((c) => c.kind === 'direct').map((c) => c.agent_key), this.sessions ?? 'unavailable' ) ); + /** + * The agents to start or open a chat with. No unread here: the badge reads + * chatStore.unreadByNumber (Q7). + */ + roster = $derived.by((): RosterEntry[] => { + const numbers = new Map(); + for (const c of this.conversations) { + if (c.kind !== 'direct') continue; + const k = normalizeAgentName(c.agent_key); + if (k !== '' && !numbers.has(k)) numbers.set(k, c.number); + } + const out: RosterEntry[] = []; + for (const [key, presence] of this.presences) { + out.push({ key, presence, number: numbers.get(key) ?? null }); + } + return out.sort( + (a, b) => ROSTER_RANK[a.presence] - ROSTER_RANK[b.presence] || a.key.localeCompare(b.key) + ); + }); + + /** Agent keys with an ensure POST in flight. REPLACED on each change, never mutated (K3). */ + ensuring = $state>(new Set()); + /** Keys whose ensure was rate limited and is cooling down. */ + rateLimited = $state>({}); + /** Keys whose last ensure failed for another reason. */ + ensureFailed = $state>({}); + #seq = 0; #stopRoot: (() => void) | null = null; #presenceSeq = 0; #presenceActive = false; + #presenceOwners = 0; + #cooldowns = new Map>(); #presenceInFlight = false; #presenceStartedAt = 0; #presenceTimer: ReturnType | null = null; @@ -67,6 +117,12 @@ class ChatInboxStore { this.conversations = []; this.loaded = false; this.failed = false; + // An ensure answer from before this point is fenced; its state goes too (K1, K2). + this.ensuring = new Set(); + this.rateLimited = {}; + this.ensureFailed = {}; + for (const t of this.#cooldowns.values()) clearTimeout(t); + this.#cooldowns.clear(); }); } @@ -95,9 +151,13 @@ class ChatInboxStore { ); } - /** Poll the sessions list while the tab is visible. A second call is a no-op. */ + /** + * Poll the sessions list while the tab is visible. Reference counted: pages + * that overlap during a navigation each start and stop once, and the poll + * runs until the last owner stops. + */ startPresence(): void { - if (this.#presenceActive) return; + if (++this.#presenceOwners > 1) return; this.#presenceActive = true; this.#presenceStartedAt = Date.now(); document.addEventListener('visibilitychange', this.#onVisibility); @@ -105,6 +165,8 @@ class ChatInboxStore { } stopPresence(): void { + if (this.#presenceOwners === 0) return; + if (--this.#presenceOwners > 0) return; if (!this.#presenceActive) return; this.#presenceActive = false; // A late answer from before the stop must not land after a later start. @@ -152,6 +214,51 @@ class ChatInboxStore { } } + /** + * Create-or-reuse the DM with an agent and reload the list. Returns the + * conversation number, or null when nothing was started (an ensure already in + * flight, a failure, a rate limit, or an identity change meanwhile). A 429 is + * never retried: the POST has no idempotency key, so the button stays off for + * at least ENSURE_COOLDOWN_MS. + */ + async ensureDM(rawKey: string): Promise { + const key = normalizeAgentName(rawKey); + if (key === '' || this.ensuring.has(key) || this.rateLimited[key]) return null; + this.ensuring = new Set(this.ensuring).add(key); + this.ensureFailed = withoutKey(this.ensureFailed, key); + const isSameIdentity = authStore.identityFence(); + try { + const res = await api.chat.ensureConversation(key); + if (!isSameIdentity()) return null; + await this.load(); + if (!isSameIdentity()) return null; + return res.conversation.number; + } catch (err) { + if (!isSameIdentity()) return null; + const e = err as { code?: string; retryAfterMs?: number } | null; + if (e?.code === 'rate_limited') { + this.rateLimited = { ...this.rateLimited, [key]: true }; + const delay = Math.max(e.retryAfterMs ?? 0, ENSURE_COOLDOWN_MS); + this.#cooldowns.set( + key, + setTimeout(() => { + this.#cooldowns.delete(key); + this.rateLimited = withoutKey(this.rateLimited, key); + }, delay) + ); + } else { + this.ensureFailed = { ...this.ensureFailed, [key]: true }; + } + return null; + } finally { + if (isSameIdentity()) { + const next = new Set(this.ensuring); + next.delete(key); + this.ensuring = next; + } + } + } + async load(): Promise { if (!authStore.authenticated) return; const mine = ++this.#seq; @@ -170,4 +277,9 @@ class ChatInboxStore { } } +function withoutKey(rec: Record, key: string): Record { + const { [key]: _gone, ...rest } = rec; + return rest; +} + export const chatInbox = new ChatInboxStore(); diff --git a/web/src/routes/messages/+layout.svelte b/web/src/routes/messages/+layout.svelte index b7a748bce..319a607f0 100644 --- a/web/src/routes/messages/+layout.svelte +++ b/web/src/routes/messages/+layout.svelte @@ -6,6 +6,14 @@ let { children } = $props(); let ready = $state(false); + // A sign-out, or a swap to a signed-out identity, while /messages is open sends + // the visitor to /login (TASK-4044 2b-ii, row J). Read-only on authStore. + onMount(() => + authStore.onIdentityChange(() => { + if (!authStore.authenticated) goto('/login', { replaceState: true }); + }) + ); + // The root layout gives /messages no app shell, so signed-out visitors are // sent to /login here, as the console layout does. A guest is not redirected: // the API is owner-scoped and the page leaks nothing. diff --git a/web/src/routes/messages/+page.svelte b/web/src/routes/messages/+page.svelte index 4f025a0aa..e30e4d36b 100644 --- a/web/src/routes/messages/+page.svelte +++ b/web/src/routes/messages/+page.svelte @@ -1,13 +1,30 @@ @@ -19,6 +36,23 @@

Chat

+{#if chatInbox.roster.length > 0} +
    + {#each chatInbox.roster as entry (entry.key)} +
  • + +
  • + {/each} +
+{/if} +