feat(desktop): broadcast typing indicators for human users (#175)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-25 11:10:58 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 84fbf61d96
commit b74ce1da78
4 changed files with 61 additions and 1 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ const overrides = new Map([
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state
["src/shared/api/relayClientSession.ts", 740], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
["src-tauri/src/commands/agents.rs", 845], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
@@ -3,6 +3,7 @@ import * as React from "react";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { useMentions } from "@/features/messages/lib/useMentions";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import {
type BlobDescriptor,
pickAndUploadMedia,
@@ -63,6 +64,7 @@ export function MessageComposer({
const mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
const notifyTyping = useTypingBroadcast(channelId);
const [uploadState, setUploadState] = React.useState<{
status: "idle" | "uploading" | "error";
@@ -371,11 +373,15 @@ export function MessageComposer({
updateDraftSelection(event.target);
mentions.updateMentionQuery(nextContent, cursorPos);
channelLinks.updateChannelQuery(nextContent, cursorPos);
if (nextContent.trim().length > 0) {
notifyTyping();
}
},
[
updateDraftSelection,
mentions.updateMentionQuery,
channelLinks.updateChannelQuery,
notifyTyping,
],
);
@@ -0,0 +1,39 @@
import { useCallback, useRef } from "react";
import { relayClient } from "@/shared/api/relayClient";
const TYPING_SEND_INTERVAL_MS = 3_000;
/**
* Publishes kind:20002 typing indicators for the current user,
* throttled to at most once every 3 seconds per channel.
*/
export function useTypingBroadcast(channelId: string | null | undefined) {
const lastSentRef = useRef(0);
const lastChannelRef = useRef(channelId);
const channelIdRef = useRef(channelId);
channelIdRef.current = channelId;
const notifyTyping = useCallback(() => {
const id = channelIdRef.current;
if (!id) {
return;
}
// Reset throttle when channel changes.
if (lastChannelRef.current !== id) {
lastChannelRef.current = id;
lastSentRef.current = 0;
}
const now = Date.now();
if (now - lastSentRef.current < TYPING_SEND_INTERVAL_MS) {
return;
}
lastSentRef.current = now;
relayClient.sendTypingIndicator(id).catch(() => {});
}, []);
return notifyTyping;
}
@@ -125,6 +125,21 @@ export class RelayClient {
);
}
async sendTypingIndicator(channelId: string) {
// Bail when disconnected — not worth triggering a reconnect for ephemeral typing events.
if (this.wsId === null) {
return;
}
const event = await signRelayEvent({
kind: KIND_TYPING_INDICATOR,
content: "",
tags: [["h", channelId]],
});
// Fire-and-forget: no need to wait for relay acknowledgement.
void this.sendRaw(["EVENT", event]).catch(() => {});
}
async subscribeToChannel(
channelId: string,
onEvent: (event: RelayEvent) => void,