feat(web): PR 4 — kilo-chat web wiring + 3-tier presence#2924
feat(web): PR 4 — kilo-chat web wiring + 3-tier presence#2924iscekic merged 31 commits intofeat/kilo-chat-migration-pr1from
Conversation
…rpose); update all consumers
The badge_counts.badge_bucket column is a free-form string. To prevent namespace collisions as more surfaces start emitting badge updates (per-instance today, per-conversation later), centralize bucket-key derivation in @kilocode/notifications and route NotificationChannelDO through it. Mirrors the presence-context builders in @kilocode/event-service. Safe to introduce now without a data migration because PR 2's migration already wipes badge_counts.
…-chat producer Adds kiloclawInstanceContext and kiloclawConversationContext path builders to @kilocode/event-service, replacing hardcoded template literals in kilo-chat's event-push.ts and its test so all callers share a single source of truth.
When a chat message is persisted, fire-and-forget a call to NOTIFICATIONS.sendPushForConversation so non-sender human members of the conversation receive a push. Runs after realtime/event-service delivery inside postCommitFanOut, with errors swallowed so push failures cannot fail the send. - Skip when there are no other human recipients or no sandboxId. - senderUserId = callerId for human senders, null for bot senders. - title is "<sandboxLabel> · <conversationTitle>"; bodyPreview is the first 200 chars of the concatenated text blocks. - Add @kilocode/notifications workspace dep and layer the RPC method shape into Env via bindings.d.ts. - Add a notifications-stub worker to the vitest config so tests can spy on env.NOTIFICATIONS.sendPushForConversation, and globally mock sandbox-lookup in setup.ts (it imports pg via @kilocode/db).
…es, fix test mock - Remove `stream-chat` from `services/notifications/package.json`; the Stream webhook (its only consumer) was deleted earlier in the stack. - Regenerate `worker-configuration.d.ts` so the workerd runtime types match the current toolchain (sibling services were on `1.20260312.1`; this one had drifted to `1.20251217.0` from a stale local cache). - Fix the global test mock to reference the renamed `badge_counts` table; the setup file was authored against the pre-rename name and never matched. - Tidy two pre-existing lint nits in the new test files (`import type` for type-only import, drop unused `cols` parameter).
…leak - Switch `NotificationsService` from default-only to a named class export with a separate default. `services/kilo-chat/wrangler.jsonc` binds via `entrypoint: "NotificationsService"`, which resolves named module exports. The default-only form (`export default class NotificationsService`) exports under the `default` key — kilo-chat's RPC binding would not have resolved at deploy. Mirrors the existing pattern in `services/kilo-chat/src/index.ts` (`KiloChatService`). - `dispatchPush` now uses a two-stage idempotency record (`pending` → `delivered`). The badge increment was previously non-idempotent: an Expo failure returned `failed` without writing the idempotency key, so upstream retries (which the design explicitly invites) re-ran the increment before the next send and inflated the badge by one per retry. The `pending` marker is written before the increment and short-circuits the increment on retry; the `delivered` marker is only written on success. - `setAlarm` is now gated on `getAlarm() === null`. Calling `setAlarm` unconditionally on each successful push — as the previous code did — replaces the pending alarm and pushes the cleanup forward indefinitely on a conversation receiving more than one push per `IDEM_TTL_MS`, leaking expired idempotency entries. Adds two test cases covering the badge-retry and alarm-reset paths.
- Schedule the cleanup alarm when writing the `pending` marker, not only on `delivered`. Without this, an Expo failure followed by no further push activity for the conversation leaves the `pending` record in DO storage forever (no alarm was ever set to prune it). - After the alarm fires, reschedule for the earliest remaining record's expiry instead of leaving the alarm slot empty. Otherwise a quiet conversation strands its younger entries until some unrelated future dispatch wakes the DO up. Both paths go through a small `ensureCleanupAlarm` helper that gates on `getAlarm() === null` so a busy conversation still doesn't push the alarm forward on every call.
The kiloclaw-scoped presence paths are literally `/presence` prefixed
onto the kiloclaw event-context paths. Build them by composition so the
`/kiloclaw/{sandboxId}[/{conversationId}]` segment shape is defined in
exactly one place — `kiloclaw-contexts.ts`.
Pure refactor; same string output, template-literal types still narrow
to the same shape.
Introduces a single app-shell EventServiceProvider that owns the EventServiceClient and KiloChatClient for all authenticated routes. Mounted in (app)/layout.tsx so platform/instance/conversation presence subscriptions and the kilo-chat UI share one WebSocket. KiloChatLayout now consumes the global clients via useEventServiceClient() instead of spinning up its own pair, and the getToken prop is removed from KiloChatLayoutProps (along with both call sites). The local useEventService(getToken) factory is dead code and has been deleted; useInstanceContext / useConversationContext stay since they take EventServiceClient as a parameter.
Thin hook that subscribes the global EventServiceClient to a single context for the lifetime of the calling component, gated by an `active` flag. Will back upcoming platform- and instance-level presence indicators.
…eSubscription - Drop dead getToken field from KiloChatContextValue (no consumers). - Remove useInstanceContext / useConversationContext hooks; both call sites now use the shared usePresenceSubscription primitive directly. - Harden usePresenceSubscription against empty-string contexts.
- usePresenceSubscription: accept 'string | null' instead of empty-string sentinel; update call sites (KiloChatLayout, MessageArea, useInstancePresence) - kilo-chat router: validate expiresAt with z.iso.datetime() - kilo-chat-router test: verify the JWT payload (kiloUserId, tokenSource, version) and that expiresAt lands in the expected ~1h window - MessageArea: comment distinguishing the always-on chat-event subscription from the visibility-gated presence subscription
Code Review SummaryStatus: 1 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Resolved
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (1 file)
Fix these issues in Kilo Cloud Reviewed by gpt-5.5-20260423 · 610,845 tokens |
connect() awaits getToken() before constructing the WebSocket. If disconnect() runs in that window (provider unmount, sign-out, strict-mode remount) the in-flight token fetch resolves and we'd construct a fresh socket + start the ping timer with no React owner left to clean it up. Re-check this.destroyed after the await and bail before creating the socket.
| // disconnect() may have run while we were awaiting the token. Bail before | ||
| // creating the socket so we don't leak a WebSocket + ping timer past | ||
| // provider unmount (e.g. sign-out, navigation, strict-mode remount). | ||
| if (this.destroyed) return; |
There was a problem hiding this comment.
WARNING: Stale connect attempts can still open duplicate sockets
This guard only checks the shared destroyed flag. If connect A is awaiting getToken(), cleanup calls disconnect(), and then a new connect B starts before A resumes, B sets destroyed = false; A then passes this check and opens a stale WebSocket after its pre-await close check already ran. That can leave duplicate sockets/subscriptions alive during strict-mode remounts or quick reconnect cycles. Use a per-connect generation/abort token and verify it after awaits/before open handlers, or otherwise ensure stale attempts close themselves instead of relying on the shared destroyed boolean.
…to feat/kilo-chat-migration-pr4 # Conflicts: # packages/notifications/src/badge-buckets.ts # services/kilo-chat/wrangler.jsonc
Summary
PR 4 of the kilo-chat migration. Wires web to consume the kiloclaw event-context helpers added in PR 3 and adds the three-tier presence subscriptions (platform / instance / conversation) on top of a newly-lifted global `EventServiceProvider`.
State after merge: web kilo-chat UI honors presence and the existing kilo-chat web flow is fully wired. Stream ChatTab still exists alongside; cutover happens in PR 6.
Changes
Behavior change worth flagging
The global `EventServiceProvider` opens a WebSocket on every authenticated route, not just kilo-chat. This is intentional — platform/instance presence subscriptions need it — but it means idle authenticated tabs hold a WS to the event service. Failure mode is benign: `onUnauthorized` clears the cached token and stops the socket, so a kilo-chat outage does not break unrelated routes.
Test plan
Related
Plan: `docs/superpowers/plans/2026-04-29-mobile-kilo-chat-migration.md` — PR 4 (Tasks 18, 18b, 19, 20, 21, 22).
Stack: PR #2907 → PR #2914 → PR #2918 → this PR.