Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/features/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions packages/sdk-ts/src/ephemeral-event.type-tests.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { AgentProgressPayload, EphemeralEventInput } from "./index";

type Assert<T extends true> = T;
type IsAssignable<Input, Target> = Input extends Target ? true : false;
type IsRejected<Input, Target> = 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>
>;
2 changes: 1 addition & 1 deletion packages/sdk-ts/src/generated/openapi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
55 changes: 48 additions & 7 deletions packages/sdk-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +408 to +411

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep progress line types aligned with supported frames

When SDK users publish the progress frames already supported elsewhere in the app, this new public type rejects them: AgentProgress.svelte renders line kinds like plan, patch, and command_output, and the HTTP agent-progress tests still send line.toolName, but AgentProgressLine only allows four kinds and only the snake_case tool_name. Fresh object literals using those supported shapes now fail to type-check through publishEphemeral, so integrations cannot use the typed SDK for existing progress updates without casts.

Useful? React with 👍 / 👎.

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<string, unknown>;
}
);

type PresenceEventInput = {
workspaceId: string;
type: "presence.changed";
payload?: Record<string, unknown>;
} & OptionalEphemeralEventTarget;

export type EphemeralEventInput = TargetedEphemeralEventInput | PresenceEventInput;

export type RealtimeEventPage = {
events: RealtimeEvent[];
tailCursor?: string;
Expand Down Expand Up @@ -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<string, unknown>;
}): Promise<RealtimeEvent> => {
publishEphemeral: async (input: EphemeralEventInput): Promise<RealtimeEvent> => {
const data = await this.request<{ event: RealtimeEvent }>("/api/realtime/ephemeral", {
method: "POST",
body: JSON.stringify({
Expand Down