diff --git a/CHANGELOG.md b/CHANGELOG.md index 275800b8..4c33449e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Allowed disabled Pushover notification settings to clear the stored user key through the public API contract. - Unified the desktop shell chrome like Slack: title bar, workspace rail, and sidebar share one continuous plate with the conversation floating on it as a rounded card, the sidebar workspace header and the in-card channel header are gone on desktop — the workspace name (click for workspace settings) and the current channel or DM title live in the title bar — and the always-on "Connected" labels are gone everywhere (app settings stay on the native menu and Cmd/Ctrl+,; a pulsing "Connecting…" note appears only while the realtime link is down). The browser app keeps its sidebar workspace header and channel header unchanged. - Fixed live agent-activity bursts so same-turn preambles grow in place without dropping realtime rows or pulling a bottom-pinned timeline away from the live edge. +- Added typed agent-progress SDK payloads while preserving workspace-wide presence events without channel or DM targets. Thanks @arcabotai for surfacing the integration needs. - Softened message bubbles: hairline accent-tint borders replace the heavier outlines, gentler keycap under-edges, and roomier padding. - Added Appearance settings with a light/dark/system color mode and four full-app board themes (Signal, the heritage Ember, Moss, Iris) — each tuned for both modes via a light-dark() token refactor, applied instantly with live swatch previews, persisted per device, and flash-free on load. - Redesigned the product site around the "Switchboard" keycap identity: cool porcelain/night-board themes over a dot-grid plate, electric cyan accent with indigo bot/thread tones, keycap buttons and step markers with press states, Bricolage Grotesque display type, and mono kickers/labels. diff --git a/docs/features/realtime.md b/docs/features/realtime.md index 8fd6e70c..f671b7ed 100644 --- a/docs/features/realtime.md +++ b/docs/features/realtime.md @@ -106,6 +106,10 @@ fall back to a workspace-wide broadcast. `POST /api/realtime/ephemeral` validates workspace membership and tags the payload with `user_id` from the caller before publishing. +The TypeScript SDK exports `AgentProgressLine`, `AgentProgressPayload`, and +`EphemeralEventInput`. Its input union requires one target for typing and agent +progress while retaining targetless, workspace-wide presence events. + ## Recovery rules - The client sends `after_cursor` on every connect/reconnect. diff --git a/docs/sdk.md b/docs/sdk.md index 69e81af7..7193ad3c 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -114,6 +114,26 @@ const socket = client.events.subscribe({ `subscribe` returns a raw `WebSocket`. Call `.close()` to disconnect. See [features/realtime.md](features/realtime.md) for cursor recovery rules. +Bot tokens can publish typed, target-scoped agent progress: + +```ts +await client.events.publishEphemeral({ + workspaceId, + channelId, + type: "agent.progress", + payload: { + turn_id: sourceMessageId, + seq: 1, + op: "append", + line: { id: "tool-1", kind: "tool", tool_name: "web_search", status: "running" }, + }, +}); +``` + +`agent.progress` requires a bot token and exactly one channel or DM target. +Typing events have the same target rule. Presence events may instead omit both +targets to publish workspace-wide. + ## Generated types `packages/sdk-ts/src/generated/openapi.d.ts` is generated from diff --git a/packages/protocol/openapi.yaml b/packages/protocol/openapi.yaml index 6c721e01..d16204c9 100644 --- a/packages/protocol/openapi.yaml +++ b/packages/protocol/openapi.yaml @@ -2983,7 +2983,7 @@ components: type: string type: type: string - enum: [typing.started, typing.stopped, presence.changed] + enum: [typing.started, typing.stopped, presence.changed, agent.progress] payload: type: object additionalProperties: true diff --git a/packages/sdk-ts/src/ephemeral-event.type-tests.d.ts b/packages/sdk-ts/src/ephemeral-event.type-tests.d.ts new file mode 100644 index 00000000..7a5a28f2 --- /dev/null +++ b/packages/sdk-ts/src/ephemeral-event.type-tests.d.ts @@ -0,0 +1,72 @@ +import type { AgentProgressPayload, EphemeralEventInput } from "./index"; + +type Assert = T; +type IsAssignable = Input extends Target ? true : false; +type IsRejected = Input extends Target ? false : true; + +type ProgressPayload = { + turn_id: "turn-1"; + op: "append"; + line: { id: "tool-1"; kind: "tool"; tool_name: "shell"; status: "running" }; +}; + +type _WorkspacePresenceAllowed = Assert< + IsAssignable< + { workspaceId: "wsp-1"; type: "presence.changed"; payload: { status: "away" } }, + EphemeralEventInput + > +>; +type _TargetedPresenceAllowed = Assert< + IsAssignable< + { workspaceId: "wsp-1"; channelId: "chn-1"; type: "presence.changed" }, + EphemeralEventInput + > +>; +type _TargetedTypingAllowed = Assert< + IsAssignable< + { workspaceId: "wsp-1"; directConversationId: "dm-1"; type: "typing.started" }, + EphemeralEventInput + > +>; +type _TargetedProgressAllowed = Assert< + IsAssignable< + { + workspaceId: "wsp-1"; + channelId: "chn-1"; + type: "agent.progress"; + payload: ProgressPayload; + }, + EphemeralEventInput + > +>; +type _ClearProgressAllowed = Assert< + IsAssignable<{ turn_id: "turn-1"; op: "clear" }, AgentProgressPayload> +>; +type _TargetlessTypingRejected = Assert< + IsRejected<{ workspaceId: "wsp-1"; type: "typing.stopped" }, EphemeralEventInput> +>; +type _TargetlessProgressRejected = Assert< + IsRejected< + { + workspaceId: "wsp-1"; + type: "agent.progress"; + payload: ProgressPayload; + }, + EphemeralEventInput + > +>; +type _DualTargetProgressRejected = Assert< + IsRejected< + { + workspaceId: "wsp-1"; + channelId: "chn-1"; + directConversationId: "dm-1"; + type: "agent.progress"; + payload: ProgressPayload; + }, + EphemeralEventInput + > +>; +type _ProgressLineRequired = Assert< + IsRejected<{ turn_id: "turn-1"; op: "append" }, AgentProgressPayload> +>; diff --git a/packages/sdk-ts/src/generated/openapi.d.ts b/packages/sdk-ts/src/generated/openapi.d.ts index d1ed6153..880a0818 100644 --- a/packages/sdk-ts/src/generated/openapi.d.ts +++ b/packages/sdk-ts/src/generated/openapi.d.ts @@ -1814,7 +1814,7 @@ export interface components { channel_id?: string; direct_conversation_id?: string; /** @enum {string} */ - type: "typing.started" | "typing.stopped" | "presence.changed"; + type: "typing.started" | "typing.stopped" | "presence.changed" | "agent.progress"; payload?: { [key: string]: unknown; }; diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index 6949e832..f4b85615 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -403,6 +403,53 @@ export type RealtimeEvent = { payload: RealtimeEventPayload; }; +export type AgentProgressLine = { + id: string; + kind: "commentary" | "lifecycle" | "thinking" | "tool"; + text?: string; + title?: string; + tool_name?: string; + status?: string; +}; + +export type AgentProgressPayload = { + turn_id: string; + seq?: number; +} & ( + | { op: "append" | "update" | "finalize"; line: AgentProgressLine } + | { op: "clear"; line?: never } +); + +type EphemeralEventTarget = + | { channelId: string; directConversationId?: never } + | { channelId?: never; directConversationId: string }; + +type OptionalEphemeralEventTarget = + | EphemeralEventTarget + | { channelId?: never; directConversationId?: never }; + +type TargetedEphemeralEventInput = { + workspaceId: string; +} & EphemeralEventTarget & + ( + | { + type: "agent.progress"; + payload: AgentProgressPayload; + } + | { + type: "typing.started" | "typing.stopped"; + payload?: Record; + } + ); + +type PresenceEventInput = { + workspaceId: string; + type: "presence.changed"; + payload?: Record; +} & OptionalEphemeralEventTarget; + +export type EphemeralEventInput = TargetedEphemeralEventInput | PresenceEventInput; + export type RealtimeEventPage = { events: RealtimeEvent[]; tailCursor?: string; @@ -1130,13 +1177,7 @@ export class ClickClackClient { ...(data.tail_cursor !== undefined ? { tailCursor: data.tail_cursor } : {}), }; }, - publishEphemeral: async (input: { - workspaceId: string; - channelId?: string; - directConversationId?: string; - type: "typing.started" | "typing.stopped" | "presence.changed"; - payload?: Record; - }): Promise => { + publishEphemeral: async (input: EphemeralEventInput): Promise => { const data = await this.request<{ event: RealtimeEvent }>("/api/realtime/ephemeral", { method: "POST", body: JSON.stringify({