Skip to content

feat: kilo-chat — plugin, backend, event service, and web UI#2361

Merged
iscekic merged 560 commits intomainfrom
feat/kiloclaw-kilo-chat-plugin
Apr 27, 2026
Merged

feat: kilo-chat — plugin, backend, event service, and web UI#2361
iscekic merged 560 commits intomainfrom
feat/kiloclaw-kilo-chat-plugin

Conversation

@iscekic
Copy link
Copy Markdown
Contributor

@iscekic iscekic commented Apr 13, 2026

Summary

Adds a full-stack real-time chat system that lets users talk to their KiloClaw bot instances through a web UI. The system spans 5 new services/packages and touches the kiloclaw controller, CF worker, and Dockerfile.

New services & packages:

  • services/kilo-chat — Cloudflare Worker chat backend (Hono + Durable Objects)
  • services/event-service — Cloudflare Worker real-time event relay (WebSocket + Durable Objects)
  • packages/kilo-chat — TypeScript client SDK for the chat service
  • packages/event-service — TypeScript client SDK for the event service
  • services/kiloclaw/plugins/kilo-chat — OpenClaw plugin giving the bot chat capabilities

Modified:

  • services/kiloclaw/controller — New kilo-chat proxy routes + bootstrap config
  • services/kiloclaw/src — Instance config propagation, gateway env injection
  • services/kiloclaw/Dockerfile — Plugin bundling
  • apps/web — Chat UI (Next.js app router pages + hooks)

Architecture Overview

End-to-End HTTP Flow

User sends a message → bot receives it

Browser (React)
  → POST /v1/messages (JWT auth)
  → kilo-chat Worker
    → ConversationDO.createMessage() (persist in SQLite)
    → deliverToBot() → RPC service binding → kiloclaw CF Worker
      → KiloClawInstance DO → Fly machine HTTP
        → Controller /_kilo/kilo-chat/webhook
          → OpenClaw gateway /plugins/kilo-chat/webhook
            → Agent processes message

Bot responds → user sees it in real-time

OpenClaw gateway (agent reply)
  → Plugin PreviewStream
    → Controller /_kilo/kilo-chat/send (bearer token)
      → kilo-chat Worker /bot/v1/sandboxes/:sandboxId/messages
        → ConversationDO.createMessage()
        → pushEventToHumanMembers()
          → event-service Worker → UserSessionDO
            → WebSocket push to browser
              → React Query cache update → UI re-render

Approval flow (tool-use gating)

Agent needs approval → Plugin deliverPending()
  → POST bot message with ActionsBlock (approve/deny buttons)
    → User sees buttons in chat UI → clicks one
      → POST /v1/conversations/:id/messages/:id/execute-action
        → ConversationDO resolves action block
        → deliverActionExecutedToBot() → RPC → kiloclaw
          → Controller webhook → gateway → resolveApprovalOverGateway

Key Design Decisions

  • Two Durable Objects in kilo-chat: ConversationDO holds shared message/member state (one per conversation); MembershipDO denormalizes per-user conversation metadata (activity timestamps, read position) so listing conversations doesn't require reading every ConversationDO.
  • Event service as separate worker: Decouples real-time delivery from chat logic; UserSessionDO is per-user (not per-conversation), so a single WebSocket carries events for all conversations.
  • Ticket-based WebSocket auth: Two-step flow (POST for ticket → WS upgrade with ticket) prevents JWT exposure in WebSocket URLs.
  • Gateway token derivation: Per-sandbox HMAC-SHA256 tokens (deriveGatewayToken(sandboxId, secret)) — no per-instance secret management needed.
  • Plugin bundled in Docker image: The OpenClaw plugin is npm-packed and globally installed in the Fly machine image, referenced by path in config-writer.

1. Kilo-Chat Worker (services/kilo-chat)

Durable Objects

ConversationDO — one per conversation, SQLite-backed:

  • Tables: conversation, members, messages, reactions
  • ULID-based message IDs with cursor pagination
  • Optimistic concurrency on edits (client timestamp comparison)
  • Soft-delete for messages; tombstone IDs for reaction removal
  • Action blocks: stores interactive button groups in message content, resolves with value/resolvedBy/resolvedAt

MembershipDO — one per user/bot:

  • Single conversations table indexed by sandboxId
  • Tracks joinedAt, lastActivityAt, lastReadAt per conversation
  • Bulk cleanup via removeConversationsBySandbox() for sandbox destruction

Auth Model

Surface Auth Caller identity
/v1/* (user) Bearer JWT (NEXTAUTH_SECRET) kiloUserId from token
/bot/v1/sandboxes/:sandboxId/* (bot) HMAC gateway token (timing-safe) bot:kiloclaw:{sandboxId}

Sandbox ownership verified against Hyperdrive (PostgreSQL) at conversation creation.

API Endpoints

User-facing (/v1/):

Method Endpoint Purpose
POST /v1/conversations Create conversation (user must own sandbox)
GET /v1/conversations List conversations (filterable by sandboxId, paginated)
GET /v1/conversations/:id Get conversation details
PATCH /v1/conversations/:id Rename conversation
POST /v1/conversations/:id/leave Leave conversation
POST /v1/conversations/:id/mark-read Mark as read
POST /v1/messages Send message (triggers bot webhook + event fan-out)
GET /v1/conversations/:id/messages List messages (before-cursor pagination)
PATCH /v1/messages/:id Edit message (sender only, concurrency check)
DELETE /v1/messages/:id Soft-delete message
POST /v1/conversations/:cid/messages/:mid/execute-action Execute action block
POST /v1/messages/:id/reactions Add reaction
DELETE /v1/messages/:id/reactions Remove reaction

Bot-facing (/bot/v1/sandboxes/:sandboxId/):

Method Endpoint Purpose
POST /messages Bot sends message
PATCH /messages/:id Bot edits message
DELETE /messages/:id Bot deletes message
POST/DELETE /messages/:id/reactions Bot add/remove reaction
POST /conversations/:id/typing Bot typing indicator
POST /conversations/:id/typing/stop Bot typing stop
GET /conversations/:id/messages Bot lists messages
GET /conversations/:id/members Bot gets enriched member list
PATCH /conversations/:id Bot renames conversation
GET /conversations Bot lists conversations
POST /conversations Bot creates conversation

Event Fan-Out

After mutations, the service pushes events via event-service binding:

Conversation-scoped (/kiloclaw/{sandboxId}/{conversationId}):
message.created, message.updated, message.delivery_failed, typing.set, typing.stop

Instance-scoped (/kiloclaw/{sandboxId}):
conversation.created, conversation.renamed, conversation.activity, conversation.read, conversation.left

Users currently in a conversation are auto-marked read; others receive conversation.activity for unread indication.


2. Event Service (services/event-service + packages/event-service)

Architecture

CF Worker + two Durable Objects:

  • TicketDO (singleton): Issues 30-second single-use tickets, cleaned by alarm every 60s
  • UserSessionDO (per-user): Manages WebSocket connection pool, routes events to subscribed contexts

Connection Protocol

1. POST /connect/ticket (Bearer JWT) → { ticket } (30s TTL)
2. GET  /connect?ticket=X&userId=Y  → WebSocket upgrade → UserSessionDO
3. Client sends: context.subscribe { contexts: [...] }
4. Client sends: presence.ping (every 5s keepalive)
5. Server pushes: { type: 'event', context, event, payload }

Client SDK (packages/event-service)

EventServiceClient provides:

  • connect() / disconnect() with exponential backoff reconnection (up to 30s)
  • subscribe(contexts) / unsubscribe(contexts) with queued replay on reconnect
  • on(event, handler) returning unsubscribe function
  • onReconnect(handler) for cache invalidation on reconnect
  • Automatic presence ping and context resubscription after reconnect

3. KiloClaw Changes (services/kiloclaw)

Controller: Kilo-Chat Proxy Routes

New controller/src/routes/kilo-chat.ts — 11 REST routes proxying plugin requests to kilo-chat Worker:

  • Auth: bearer token validated against expected sandbox gateway token (timing-safe)
  • Body size limits: 1MB for send/edit, 8KB for metadata
  • Relays to {kiloChatBaseUrl}/bot/v1/sandboxes/{sandboxId}/* with same bearer token
  • Returns 502 on upstream failures

Bootstrap & Config Writer

  • bootstrap.ts: Passes KILOCHAT_BASE_URL to container env; registers routes when both KILOCLAW_SANDBOX_ID and KILOCHAT_BASE_URL are present
  • config-writer.ts: Always enables kilo-chat channel (_configured = true); adds plugin path /usr/local/lib/node_modules/@kiloclaw/kilo-chat to plugins.load.paths
  • Seeds exec-approvals.json defaults (security policy, ask mode, askFallback) on first boot

CF Worker DO Changes

  • InstanceConfig schema: adds vectorMemoryEnabled, vectorMemoryModel, dreamingEnabled
  • buildEnvVars(): injects KILOCHAT_BASE_URL as plaintext binding; gateway token derived per-sandbox (no stored secrets)

Dockerfile

  • New kilo-chat-builder stage: copies plugin source, runs npm install + npm pack, produces tarball
  • Final stage: npm install -g the tarball to /usr/local/lib/node_modules/@kiloclaw/kilo-chat
  • Deploy workflow: source hash includes plugins/kilo-chat/ directory for content-addressed image tagging

4. OpenClaw Plugin (services/kiloclaw/plugins/kilo-chat)

Plugin System Integration

  • Manifest (openclaw.plugin.json): channel-kind plugin, ID kilo-chat, markdown support
  • Registers via createChatChannelPlugin with actions, webhooks, approval capability, and preview streaming

Actions (Bot Capabilities)

Action Parameters Description
read limit?, before? List messages in conversation
member-info Get conversation members
react emoji, remove? Add/remove emoji reaction (shortcode normalization)
edit message Edit message text (409 stale handling)
delete Delete a message
renameGroup name Rename conversation
channel-list limit?, offset? List all conversations
channel-create name?, additionalMembers? Create new conversation

Webhook Handler

Single route at /plugins/kilo-chat/webhook (auth: 'plugin'):

  • message.created: Parses inbound message → resolves session/agent route → dispatches to agent
  • action.executed: Parses approval button click → calls resolveApprovalOverGateway

Preview Streaming

PreviewStream class (500ms throttle window):

  • First token → POST creates message, records messageId
  • Subsequent tokens → coalesced PATCH with latest text + monotonic timestamp
  • Finalize → final PATCH with complete text; abort → DELETE
  • Typing indicators sent every 3s during active reply

Approval Delivery

createKiloChatApprovalCapability():

  • Renders approvals as rich messages with metadata + ActionsBlock buttons
  • Session key format: agent:{agentId}:direct:{conversationId}
  • Resolution: action.executed webhook → resolveApprovalOverGateway

5. Web UI (apps/web)

Route Structure

  • /claw/kilo-chat — Layout + conversation list
  • /claw/kilo-chat/[conversationId] — Conversation detail (dynamic route)
  • Feature-flagged via kilo-chat-feature in sidebar

Real-Time Architecture

  • KiloChatLayout creates singleton EventServiceClient + KiloChatClient
  • WebSocket connects on mount; subscribes to instance + conversation contexts
  • Events update React Query cache directly via useMessageCacheUpdater()
  • Reconnect invalidates all caches to recover missed events
  • Optimistic updates on all mutations with rollback on error
  • Duplicate deduplication via clientId correlation

Token Flow

Browser → POST /api/kilo-chat/token (session cookie)
  → NextAuth session → mint 1hr JWT (kiloUserId payload)
  → Cached in module scope with 5min refresh buffer
  → Used by both EventServiceClient and KiloChatClient

Key UI Features

  • Conversations: Grouped by recency, unread indicators (lastReadAt vs lastActivityAt), inline rename/delete
  • Messages: Infinite scroll (50/page), auto-scroll to bottom (MutationObserver), markdown rendering (react-markdown + remark-gfm)
  • Message actions: Hover toolbar — React, Copy, Edit, Delete, Reply
  • Reactions: Quick-pick (6 presets) + full emoji-mart picker, toggle add/remove
  • Action blocks: Styled buttons (green/red/gray), resolved state with checkmark/X
  • Typing indicators: 3s debounced send, 5s display timeout, excludes current user
  • Instance switching: Dropdown for multi-sandbox support

6. Shared Packages & Infrastructure

kilo-chat Client SDK (packages/kilo-chat)

KiloChatClient wraps all HTTP calls with Bearer JWT auth:

  • Mutations: sendMessage, editMessage, deleteMessage, createConversation, renameConversation, leaveConversation, markConversationRead, addReaction, removeReaction, executeAction, sendTyping/sendTypingStop
  • Queries: listConversations, getConversation, listMessages
  • Events: typed handlers for all 13 event types (message., typing., reaction., conversation.)
  • Errors: KiloChatApiError(status, body) for non-2xx responses
  • Utilities: ulidToTimestamp(), contentBlocksToText()

DB Migrations

  • 0099: bot_request_cloud_agent_sessions table (session tracking for bot requests)
  • 0100: blocked_at + blocked_by_kilo_user_id columns on kilocode_users

Local Dev

  • kilo-chat added to kiloclaw service group in dev/local/services.ts
  • Depends on ['kiloclaw', 'event-service']
  • NEXT_PUBLIC_KILO_CHAT_URL env var for web app → chat service connection

@iscekic iscekic self-assigned this Apr 13, 2026
Comment thread packages/kiloclaw-secret-catalog/src/catalog.ts Outdated
Comment thread services/kiloclaw/plugins/kilo-chat/src/webhook.ts Outdated
Comment thread services/kiloclaw/plugins/kilo-chat/src/client.ts Outdated
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot Bot commented Apr 13, 2026

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0

Fix these issues in Kilo Cloud

Issue Details (click to expand)

WARNING

File Line Issue
services/kiloclaw/plugins/kilo-chat/src/webhook.ts 62 sentAt is only checked for non-empty string content, so malformed timestamps flow into Date.parse() as NaN.
Other Observations (not in diff)

None.

Files Reviewed (17 files)
  • packages/kiloclaw-secret-catalog/src/__tests__/catalog.test.ts - 0 issues
  • packages/kiloclaw-secret-catalog/src/catalog.ts - 0 issues
  • services/kiloclaw/controller/src/config-writer.test.ts - 0 issues
  • services/kiloclaw/controller/src/config-writer.ts - 0 issues
  • services/kiloclaw/controller/src/routes/kilo-chat.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/.gitignore - 0 issues
  • services/kiloclaw/plugins/kilo-chat/README.md - 0 issues
  • services/kiloclaw/plugins/kilo-chat/openclaw.plugin.json - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/channel.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/channel.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/client.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/client.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/preview-stream.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/preview-stream.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/webhook.test.ts - 0 issues
  • services/kiloclaw/plugins/kilo-chat/src/webhook.ts - 1 issue
  • services/kiloclaw/src/routes/kiloclaw.test.ts - 0 issues

Reviewed by gpt-5.4-20260305 · 3,344,263 tokens

Comment thread services/kiloclaw/plugins/kilo-chat/src/preview-stream.ts Outdated
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot Bot commented Apr 13, 2026

Code Review Summary

Status: 16 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 16
SUGGESTION 0

Fix these issues in Kilo Cloud

Issue Details (click to expand)

WARNING

File Line Issue
services/kiloclaw/plugins/kilo-chat/src/webhook.ts 42 sentAt is only checked for non-empty string content, so malformed timestamps flow into Date.parse() as NaN.
services/kilo-chat/src/routes/messages.ts 13 conversationId in create/edit/delete is only validated as a non-empty string before calling CONVERSATION_DO.idFromName(...), unlike the other conversation routes that require a ULID.
services/kilo-chat/src/do/conversation-do.ts 435 Stale-edit ordering is based on client wall-clock timestamps, so edits from a device with a slower clock can be discarded forever even when they happen later in real time.
apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx 123 The conversation event subscription closes over currentUserId but omits it from the effect dependencies, so handlers registered before useUser() resolves can ignore the real user's read events and leave sidebar unread state stale.
services/kilo-chat/src/services/messages.ts 68 Direct webhook delivery now waits on unbounded Promise.all(...) RPCs inside waitUntil, so a hung bot delivery can leave long-lived pending work that ties up worker resources.
services/kiloclaw/plugins/kilo-chat/src/list-conversations-action.ts 22 The new conversations action always calls listConversations({}), so caller-supplied limit and offset are silently ignored and pagination cannot work.
services/kiloclaw/scripts/build-local-image.sh 57 --openclaw-tag now runs git checkout inside the user-supplied OpenClaw repo and never restores the prior branch, so a local image build mutates another working tree and can disrupt or fail on dirty checkouts.
services/kiloclaw/controller/src/pairing-cache.ts 367 Auto-approving gateway-client devices recurses back into refreshDevicePairingInternal() even when every approval attempt failed, so one stuck pending request can trigger unbounded self-recursion and repeated approval attempts.
apps/web/src/app/admin/components/AbuseBulkBlock.tsx 217 The recent-block drilldown only filters /admin/users by notesSearch=row.blocked_reason, so rows with the same reason collapse together and the linked user list can include the wrong bulk-block batch.
apps/web/src/app/(app)/claw/components/OpenclawImportCard.tsx 277 The import success path restarts KiloClaw even when result.ok is false, so a partially failed OpenClaw import can boot the instance into a mixed old/new workspace state before the user can inspect the failures.
apps/web/src/app/(app)/claw/kilo-chat/components/EmojiPicker.tsx 35 The picker portal computes its fixed coordinates only once from anchorRef, so scrolling, resizing, or moving the anchor while it stays open leaves the emoji picker detached from the trigger until reopened.
services/kiloclaw/controller/src/routes/config.ts 163 /_kilo/config/replace writes openclaw.json but never signals the running gateway afterward, so the API can return success while the process continues serving stale config until a later reload or restart.
services/kilo-chat/src/index.ts 109 destroyAndReturnMembers() is retried after it has already deleted the conversation, so a retryable RPC failure can make sandbox cleanup skip every human MembershipDO.removeConversation(...) call and leave orphaned conversation rows behind.
services/kilo-chat/src/services/sandbox-ownership-cached.ts 25 cachedUserOwnsSandbox() caches authorization decisions for five minutes with no invalidation path, so users who just lost sandbox access can still create new conversations until the stale true entry expires.
services/kilo-chat/src/services/conversations.ts 35 fanOutAddConversation() records successful membership writes only after each RPC resolves, so a transport error after a successful MembershipDO.addConversation() can make rollback skip that member and leave a stray conversation row behind after the conversation itself is destroyed.
services/kilo-chat/drizzle/membership/0000_watery_silvermane.sql 10 Replacing the original membership migration history with a new squashed 0000 entry will make already-migrated DO/local databases try to re-run CREATE TABLE conversations, causing startup failures on existing state.
Other Observations (not in diff)

None.

Files Reviewed (4 files)
  • apps/web/src/app/(app)/claw/kilo-chat/components/ConversationItem.tsx - 0 issues
  • apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx - 1 issue
  • services/event-service/src/do/user-session-do.ts - 0 issues
  • services/event-service/src/index.ts - 0 issues
  • services/kilo-chat/src/do/conversation-do.ts - 1 carried-forward issue, 0 new issues

Reviewed by gpt-5.5-20260423 · 10,734,987 tokens

Comment thread services/kiloclaw/plugins/kilo-chat/LOCAL_E2E.md Outdated
Comment thread services/kilo-chat/src/routes/messages.ts Outdated
Comment thread services/kilo-chat/src/index.ts Outdated
Comment thread services/kilo-chat/src/do/conversation-do.ts Outdated
Comment thread services/kilo-chat/src/routes/messages.ts Outdated
Comment thread services/kiloclaw/src/index.ts Outdated
Comment thread services/kilo-chat/src/routes/reactions.ts Outdated
Comment thread services/kilo-chat/src/do/conversation-do.ts Outdated
Comment thread services/kiloclaw/plugins/kilo-chat/src/react-action.ts Outdated
Comment thread services/kiloclaw/controller/src/routes/kilo-chat.ts Outdated
@iscekic iscekic force-pushed the feat/kiloclaw-kilo-chat-plugin branch from 73e9669 to 81556be Compare April 15, 2026 16:15
Comment thread services/kiloclaw/src/routes/kilo-chat.ts Outdated
Comment thread services/kilo-chat/src/do/conversation-do.ts Outdated
Comment thread apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx Outdated
Comment thread apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts Outdated
Comment thread apps/web/src/app/(app)/claw/kilo-chat/components/MessageBubble.tsx Outdated
Comment thread services/kilo-chat/drizzle/membership/0001_dry_synch.sql Outdated
Comment thread services/kilo-chat/src/do/conversation-do.ts
Comment thread services/kiloclaw/plugins/kilo-chat/src/preview-stream.ts Outdated
Comment thread apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts
Comment thread services/kilo-chat/src/index.ts Outdated
Comment thread services/kilo-chat/src/do/conversation-do.ts
Comment thread apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx Outdated
Comment thread apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx Outdated
Comment thread packages/event-service/src/client.ts Outdated
Comment thread packages/event-service/src/client.ts
Comment thread services/kilo-chat/src/services/messages.ts Outdated
Comment thread services/kilo-chat/src/services/typing.ts Outdated
Comment thread services/kilo-chat/src/services/messages.ts Outdated
Comment thread services/kilo-chat/src/services/messages.ts Outdated
Comment thread apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx Outdated
@iscekic iscekic changed the title feat(kiloclaw): kilo-chat channel plugin feat: kilo-chat — plugin, backend, event service, and web UI Apr 20, 2026
Comment thread services/kiloclaw/controller/src/config-writer.ts Outdated
iscekic added 2 commits April 21, 2026 13:55
…versation loading

The backend's listMessages returns content as ContentBlock[] (already
parsed from the DB JSON string). The client's parseMessageRow then tried
JSON.parse() on the already-parsed array, crashing silently. React Query
swallowed the error, showing an empty message list.

New messages were unaffected because they arrive via WebSocket events
and bypass parseMessageRow entirely.
iscekic added 25 commits April 25, 2026 22:25
…lock stream

onPartialReply emits the full cumulative text of the current assistant
message, while onBlockReply emits a chunk (a slice of that cumulative
text at chunker boundaries). When a chunker boundary fired mid-message,
the next partial still carried the cumulative text containing the
delivered chunk as a prefix; the accumulator concatenated both with
\n\n and produced a duplicated prefix in outbound PATCHes.

Treat the partial as the authoritative cumulative view of the current
message, detect new-message boundaries when a partial doesn't extend
the previous one, and fold in deliver chunks only when not already
represented. Adds a regression test that reproduces the exact
'prefix\n\nprefix+more' duplicate observed in production.
Gates the message input and Send button on the existing BotStatus
presence state (already covers explicit bot-offline, instance-not-running,
and WS-stale-for-90s cases). Prevents the silent-hanging-POST UX where
offline or unreachable sends look identical to delivered messages.
Adds `formatKiloChatError(err, fallback)` to translate KiloChatApiError
bodies (zod issues, `body.error`) into human toasts, and routes every
mutation onError through it so over-limit / empty / bad-input errors no
longer degrade to a generic 'Failed to X'. Lifts the length caps to
named constants in the kilo-chat package so the schemas, the error
phraser, and a new MessageInput char counter (shown at >=80% of the
8k text cap) all share one source of truth.
A malformed conversationId in the URL path is the only way this GET
can return 400, so surface it as the same 'Conversation not found'
toast + redirect that 403/404 already get instead of a generic
'Failed to load conversation'.
Restructures ConversationItem to the 'card-link' pattern: the row is
a <div>, a <Link> overlay covers the row for navigation, and the kebab
button + dropdown menu + leave-confirm buttons sit above the overlay
with their own pointer events. This removes the invalid <a><button>
nesting (which also caused stray navigations from menu clicks), adds
aria-labels + aria-haspopup/aria-expanded on the kebab, role=menu on
the dropdown, and aria-labels on the other icon-only buttons
(scroll-to-latest, new-conversation).
ConversationDO already assigns ULIDs in commit order, but the fan-out
in messages.ts spawned each deliverToBot call in its own worker-level
`ctx.waitUntil`, letting rapid sends race the fetch to the Fly machine
and arrive at the bot out of order. Move webhook delivery into the DO
on a `webhookChain` promise so deliveries for a given conversation
run sequentially; action.executed webhooks share the same chain so an
approval can never overtake an earlier message to the same bot.

Updates the miniflare kiloclaw-stub to buffer webhook calls in module
scope and switches the reply-context tests (which previously relied on
a route-level env override that no longer reaches the DO) to poll the
buffer. Adds a new test asserting message ULIDs arrive at the bot in
commit order when 5 sends are issued back-to-back.
Both the inline reply-preview card on each MessageBubble and the
compose-area ReplyPreview read the parent message from the live cache,
so when the parent is soft-deleted the placeholder bubble swaps to
'[deleted message]' but the quoted text stuck around. Checks
`replyToMessage.deleted` and renders a muted 'original message
deleted' instead.
…ad events by member

The message-create fan-out used to skip the sender when emitting
conversation.{read,activity}, so their own sidebar row's lastActivityAt
and lastReadAt stayed stale across all their tabs until a refetch.

Server: always emit conversation.activity to every human member, and
separately emit conversation.read to anyone who has 'read' the message
— the sender (authored it) or a recipient whose WS subscribed to the
conversation context and delivered message.created. Both events now
fire for the sender, so every tab (including ones viewing a different
conversation) gets the sidebar bump via the instance-level context.

Client: filter onConversationRead by e.memberId === currentUserId so
Alice's read marker never leaks into Bob's sidebar row — a latent bug
that became more visible now that .read fires for the sender too.
Both the header rename field and the sidebar rename field now enforce
the server's 200-char cap client-side via the shared
CONVERSATION_TITLE_MAX_CHARS constant, so the server 400 can't happen
from a user typing too much in the header (which previously had no
maxLength).
… them float

The typing sender used `void client.sendTyping(...)`, which discarded
the promise — a failed ping (offline, 5xx) then bubbled to the window
unhandledrejection handler and showed up as a console error. Typing is
best-effort; swallow silently with `.catch(() => {})`.
The card-link pattern in ConversationItem left the content row covering the
absolutely-positioned Link: the inner flex container had 'relative' without
'pointer-events-none', so clicks on the title landed on the flex div (which
has no handler) instead of the Link underneath. Mark the flex container
pointer-events-none while the overlay is showing and opt the controls
column back in with pointer-events-auto so the kebab still works.

Also pin the kebab visible and the time span hidden while the menu is open,
so the row doesn't reflow back to its unhovered layout underneath the
still-open dropdown when the mouse leaves.
The server previously accepted `"   "` as a valid conversation title
(create/rename/bot-create) and as valid message text (POST/PATCH),
persisting the whitespace verbatim. Titles rendered as empty-looking
sidebar rows; messages rendered as empty bubbles.

Introduce a shared `trimmedNonEmptyString` schema helper that trims and
requires at least one non-whitespace char, and apply it to all title
fields and to the text content block (reused by create + edit message).
Control characters remain untouched by design \u2014 scope is empty input
only.
…ersation

Leaving the currently-open conversation left its row visible in the
sidebar until a full page reload. The mutation's onSuccess invalidation
fires concurrently with the router.push away from the now-inaccessible
conversation, and the cached list never got repopulated without the
conversation.

Patch the react-query conversations cache optimistically up-front \u2014
mirroring the onConversationLeft WebSocket handler used for other
members \u2014 so the row disappears before the navigation starts. Snapshot
and restore on mutation error so the user can retry.
Pressing Enter on an edit without typing anything still fired the edit
mutation, bumping updatedAt and showing the '(edited)' label on a
message whose content had not changed. Short-circuit the save when the
trimmed new text equals the trimmed current text so no-op edits leave
the message untouched and avoid unnecessary server traffic.
'Aria is typing…' occasionally lingered for 1-3s above a newly-arrived
message because the UI only cleared typing state on an explicit
typing.stopped event or a 5s timeout, and the stopped event sometimes
arrives late (or is lost) relative to message.created.

For human senders, a fresh message.created event is itself a
deterministic end-of-typing signal, so hook the cache updater to clear
typing state for the sender when their message lands. Bots are
deliberately excluded because their streaming uses message.created for
every token chunk and relies on typing.stopped to signal stream
completion \u2014 clearing on bot messages would hide the indicator
mid-stream.
…aders

The small blue dot next to a conversation with unread messages had no
accessible name, so screen-reader users could not perceive the unread
state. Mark the dot as decorative (aria-hidden) and add an sr-only
sibling with the text 'Unread' so assistive technology announces it
while sighted users keep the compact visual affordance.
Commit 422005e made GASTOWN_URL, KILO_CHAT_URL, and EVENT_SERVICE_URL
throw at import time when the corresponding NEXT_PUBLIC_* variables are
missing. CI tests load constants.ts transitively and crashed at module
load. Set mock .test.invalid URLs so imports succeed; the invalid TLD
ensures any accidental network call fails loudly.
- Refactor KiloChatLayout to take sandboxId+basePath+noInstanceRedirect
  directly; drop InstanceSwitcher and the instances[] array prop.
- Thread isInstanceLoading through context so the index page no longer
  redirects to "new" while the status query is still in flight.
- Conversation and index pages now read basePath/noInstanceRedirect from
  KiloChatContext, making them URL-tree-agnostic.
- Mirror the kilo-chat route tree under /organizations/[id]/claw/kilo-chat
  via thin layout wiring useOrgKiloClawStatus, plus one-line re-exports
  for the index and conversation pages.
- Add the Kilo Chat item to OrganizationAppSidebar, gated on the
  kilo-chat-feature flag (or development) like the personal sidebar.
…StatusDO

Splits the single bot-status route into separate bot-status and
conversation-status routes, backed by a new SandboxStatusDO (Drizzle over
Durable SQLite) that persists last-write-wins snapshots keyed by sandbox
(bot) and conversation.

Worker:
- Drizzle schema + migration for bot_status (singleton) and conversation_status
- SandboxStatusDO with monotonic upserts (setWhere at < excluded.at), destroy
  via row DELETE (not deleteAll, to preserve schema)
- Split POST routes: /bot/v1/sandboxes/:id/bot-status and
  /bot/v1/sandboxes/:id/conversations/:cid/conversation-status
- GET routes for persisted reads with owner/membership checks
- DO wipe on sandbox destroy
- pushBotStatus / pushConversationStatus helpers resolve owner, persist,
  and emit events via event-service

Plugin (kiloclaw):
- Post-turn payload switched to sendConversationStatus
- bot-status request schema slimmed to { online, at }

Web:
- useBotStatus and useConversationStatus hooks with persisted seed +
  live WS updates, monotonic client-side guard
- Status components wired to the hooks; in-memory presence/context maps
  removed
- kilo-chat supported under org URL
The controller had a relay for /_kilo/kilo-chat/bot-status but no route
for /_kilo/kilo-chat/conversations/:conversationId/conversation-status,
so the plugin's sendConversationStatus POST fell through to the proxy's
catch-all and returned 401 controller_route_unavailable. ContextUsageRing
never received post-turn updates. Register the missing relay.
The pushEvent loop set delivered=true before calling ws.send(), so a hibernation-stale handle whose send() throws still counted as delivered. Move the assignment into the success branch so the return value reflects what actually reached a live socket.
The dual-callback then(fulfilled, rejected) form, where both branches
called the same deliverToBot, obscured the intent ("recover from prior
failures, then deliver this message in arrival order"). Replace with
.catch(() => {}).then(() => deliverToBot(...)). Same semantics, reads at
a glance.
WebSocket upgrades don't preflight, so the CORS list only governs the
426 fallback for non-upgrade GETs. Local dev hits its own wrangler dev
event-service on :8809 — there's no scenario where a localhost browser
should be talking to prod events.kiloapps.io.
ConversationItem hardcoded href to /claw/kilo-chat/<id>, so clicking a
row in the org sidebar (basePath /organizations/<id>/claw/kilo-chat)
redirected to the personal URL. Read basePath from KiloChatContext, the
same source the new-conversation and leave-redirect flows already use.
@iscekic iscekic enabled auto-merge (squash) April 27, 2026 16:09
Move KiloChatContext and useKiloChatContext into kiloChatContext.ts so
ConversationItem no longer imports from KiloChatLayout (which imports
ConversationList → ConversationItem).
@iscekic iscekic merged commit e5cc034 into main Apr 27, 2026
40 checks passed
@iscekic iscekic deleted the feat/kiloclaw-kilo-chat-plugin branch April 27, 2026 16:26
}),
];
return () => offs.forEach(off => off());
}, [kiloChatClient, queryClient]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Read events can close over an empty user id

This effect uses currentUserId inside onConversationRead() but does not include it in the dependency list. The parent passes user?.id ?? '', so the first render can register handlers with an empty id before useUser() resolves; after that, .read events for the real current user are ignored and sidebar unread state can stay stale until a reconnect/refetch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants