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
11 changes: 8 additions & 3 deletions packages/chat/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
toPlainText,
} from "./markdown";
import { Message } from "./message";
import { isPostableObject } from "./plan";
import { isPostableObject } from "./postable-object";
import type {
Adapter,
AdapterPostableMessage,
Expand Down Expand Up @@ -297,10 +297,15 @@ export class ChannelImpl<TState = Record<string, unknown>>
threadId: raw.threadId ?? this.id,
});
} else {
// Adapter doesn't support this object type - post fallback text
const fallbackText = obj.getFallbackText();
const raw = this.adapter.postChannelMessage
? await this.adapter.postChannelMessage(this.id, fallbackText)
: await this.adapter.postMessage(this.id, fallbackText);
obj.onPosted({
adapter,
messageId: `${obj.kind}_${crypto.randomUUID()}`,
threadId: this.id,
messageId: raw.id,
threadId: raw.threadId ?? this.id,
});
}
}
Expand Down
31 changes: 18 additions & 13 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,23 @@ export {
type MessageData,
type SerializedMessage,
} from "./message";
export { isPostableObject, Plan } from "./plan";
export type {
AddTaskOptions,
CompletePlanOptions,
PlanContent,
PlanModel,
PlanModelTask,
PlanTask,
PlanTaskStatus,
StartPlanOptions,
UpdateTaskInput,
} from "./plan";
export { Plan } from "./plan";
export type {
PostableObject,
PostableObjectContext,
} from "./postable-object";
export { isPostableObject } from "./postable-object";
export { StreamingMarkdownRenderer } from "./streaming-markdown";
export { type SerializedThread, ThreadImpl } from "./thread";

Expand Down Expand Up @@ -199,13 +215,12 @@ export type {
TextInputElement,
TextInputOptions,
} from "./modals";
// Types
// Types (Plan types are exported from ./plan, PostableObject types from ./postable-object)
export type {
ActionEvent,
ActionHandler,
Adapter,
AdapterPostableMessage,
AddTaskOptions,
AppHomeOpenedEvent,
AppHomeOpenedHandler,
AssistantContextChangedEvent,
Expand All @@ -218,7 +233,6 @@ export type {
ChannelInfo,
ChatConfig,
ChatInstance,
CompletePlanOptions,
CustomEmojiMap,
Emoji,
EmojiFormats,
Expand Down Expand Up @@ -246,18 +260,11 @@ export type {
ModalSubmitEvent,
ModalSubmitHandler,
ModalUpdateResponse,
PlanContent,
PlanModel,
PlanModelTask,
PlanTask,
PlanTaskStatus,
Postable,
PostableAst,
PostableCard,
PostableMarkdown,
PostableMessage,
PostableObject,
PostableObjectContext,
PostableRaw,
PostEphemeralOptions,
RawMessage,
Expand All @@ -266,14 +273,12 @@ export type {
SentMessage,
SlashCommandEvent,
SlashCommandHandler,
StartPlanOptions,
StateAdapter,
StreamOptions,
SubscribedMessageHandler,
Thread,
ThreadInfo,
ThreadSummary,
UpdateTaskInput,
WebhookOptions,
WellKnownEmoji,
} from "./types";
Expand Down
146 changes: 106 additions & 40 deletions packages/chat/src/plan.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,71 @@
import type { Root } from "mdast";
import { parseMarkdown, toPlainText } from "./markdown";
import type {
Adapter,
AddTaskOptions,
CompletePlanOptions,
PlanContent,
PlanModel,
PlanModelTask,
PlanTask,
PostableObject,
PostableObjectContext,
StartPlanOptions,
UpdateTaskInput,
} from "./types";
import {
POSTABLE_OBJECT,
type PostableObject,
type PostableObjectContext,
} from "./postable-object";
import type { Adapter } from "./types";

// =============================================================================
// Plan Types (moved from types.ts per review feedback)
// =============================================================================

export type PlanTaskStatus = "pending" | "in_progress" | "complete" | "error";

export interface PlanTask {
id: string;
status: PlanTaskStatus;
title: string;
}

export interface PlanModel {
tasks: PlanModelTask[];
title: string;
}

export interface PlanModelTask {
details?: PlanContent;
id: string;
output?: PlanContent;
status: PlanTaskStatus;
title: string;
}

export type PlanContent =
| string
| string[]
| { markdown: string }
| { ast: Root };

export interface StartPlanOptions {
/** Initial plan title and first task title */
initialMessage: PlanContent;
}

export interface AddTaskOptions {
/** Task details/substeps. */
children?: PlanContent;
title: PlanContent;
}

export type UpdateTaskInput =
| PlanContent
| {
/** Task output/results. */
output?: PlanContent;
/** Optional status override. */
status?: PlanTaskStatus;
};

export interface CompletePlanOptions {
/** Final plan title shown when completed */
completeMessage: PlanContent;
}

// =============================================================================
// Plan Implementation
// =============================================================================

/**
* Convert PlanContent to plain text for titles/labels.
Expand All @@ -35,22 +89,9 @@ function contentToPlainText(content: PlanContent | undefined): string {
return "";
}

/** Symbol identifying Plan objects */
const POSTABLE_OBJECT = Symbol.for("chat.postable");

/**
* Type guard to check if a value is a PostableObject.
*/
export function isPostableObject(value: unknown): value is PostableObject {
return (
typeof value === "object" &&
value !== null &&
(value as PostableObject).$$typeof === POSTABLE_OBJECT
);
}

interface BoundState {
adapter: Adapter;
fallback: boolean;
messageId: string;
threadId: string;
updateChain: Promise<void>;
Expand All @@ -71,7 +112,7 @@ interface BoundState {
* await plan.complete({ completeMessage: "Done!" });
* ```
*/
export class Plan implements PostableObject {
export class Plan implements PostableObject<PlanModel> {
readonly $$typeof = POSTABLE_OBJECT;
readonly kind = "plan";

Expand All @@ -91,13 +132,30 @@ export class Plan implements PostableObject {
isSupported(adapter: Adapter): boolean {
return !!adapter.postObject && !!adapter.editObject;
}

getPostData(): PlanModel {
return this._model;
}

getFallbackText(): string {
const lines: string[] = [];
lines.push(`📋 ${this._model.title || "Plan"}`);
for (const task of this._model.tasks) {
const statusIcons: Record<string, string> = {
complete: "✅",
in_progress: "🔄",
error: "❌",
};
const statusIcon = statusIcons[task.status] ?? "⬜";
lines.push(`${statusIcon} ${task.title}`);
}
return lines.join("\n");
}

onPosted(context: PostableObjectContext): void {
this._bound = {
adapter: context.adapter,
fallback: !this.isSupported(context.adapter),
messageId: context.messageId,
threadId: context.threadId,
updateChain: Promise.resolve(),
Expand Down Expand Up @@ -218,26 +276,34 @@ export class Plan implements PostableObject {
}

private canMutate(): boolean {
return !!(this._bound && this.isSupported(this._bound.adapter));
return !!this._bound;
}

private enqueueEdit(): Promise<void> {
if (!this._bound) {
return Promise.resolve();
}
const editObject = this._bound.adapter.editObject;
if (!editObject) {
return Promise.resolve();
}
const bound = this._bound;
const doEdit = async (): Promise<void> => {
await editObject.call(
bound.adapter,
bound.threadId,
bound.messageId,
this.kind,
this._model
);
if (bound.fallback) {
await bound.adapter.editMessage(
bound.threadId,
bound.messageId,
this.getFallbackText()
);
} else {
const editObject = bound.adapter.editObject;
if (!editObject) {
return;
}
await editObject.call(
bound.adapter,
bound.threadId,
bound.messageId,
this.kind,
this._model
);
}
};
const chained = bound.updateChain.then(doEdit, doEdit);
bound.updateChain = chained.then(
Expand Down
56 changes: 56 additions & 0 deletions packages/chat/src/postable-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Adapter } from "./types";

/**
* Symbol identifying PostableObject instances.
* Used by type guards to detect postable objects.
*/
export const POSTABLE_OBJECT = Symbol.for("chat.postable");

/**
* Context provided to a PostableObject after it has been posted.
*/
export interface PostableObjectContext {
adapter: Adapter;
messageId: string;
threadId: string;
}

/**
* Base interface for objects that can be posted to threads/channels.
* Examples: Plan, Poll, etc.
*
* @template TData - The data type returned by getPostData()
*/
export interface PostableObject<TData = unknown> {
/** Symbol identifying this as a postable object */
readonly $$typeof: symbol;

/**
* Get a fallback text representation for adapters that don't support this object type.
* This should return a human-readable string representation.
*/
getFallbackText(): string;

/** Get the data to send to the adapter */
getPostData(): TData;

/** Check if the adapter supports this object type */
isSupported(adapter: Adapter): boolean;

/** The kind of object - used by adapters to dispatch */
readonly kind: string;

/** Called after successful posting to bind the object to the thread */
onPosted(context: PostableObjectContext): void;
}

/**
* Type guard to check if a value is a PostableObject.
*/
export function isPostableObject(value: unknown): value is PostableObject {
return (
typeof value === "object" &&
value !== null &&
(value as PostableObject).$$typeof === POSTABLE_OBJECT
);
}
Loading