# Example: Streaming chat grid

Turn a streaming LLM response into rows with connectElementStream and append them to the grid as they arrive.

Source: https://pretable.ai/examples/streaming-chat-grid.md

```tsx ChatGrid.tsx
"use client";

import { connectElementStream } from "@pretable/stream-adapter";
import { PretableSurface, useDisposeOnUnmount } from "@pretable/react";
import { createLocalRowModel } from "@pretable/core";
import { useEffect, useMemo } from "react";

import { columns, type ChatRow } from "./columns";
import {
  responseEventsToChatRows,
  type ChatResponseEvent,
} from "./response-events-to-chat-rows";

export type OpenChatResponseEvents = (input: {
  readonly model: string;
  readonly prompt: string;
}) =>
  AsyncIterable<ChatResponseEvent> | Promise<AsyncIterable<ChatResponseEvent>>;

export function ChatGrid({
  prompt,
  openResponseEvents,
}: {
  prompt: string;
  openResponseEvents: OpenChatResponseEvents;
}) {
  const rowModel = useMemo(
    () => createLocalRowModel({ rows: [], columns, getRowId: (row) => row.id }),
    [],
  );

  useEffect(() => {
    let disposed = false;
    let connection: ReturnType<typeof connectElementStream> | undefined;
    void (async () => {
      const stream = await openResponseEvents({
        model: "gpt-5",
        prompt,
      });
      const rows: AsyncIterable<ChatRow> = responseEventsToChatRows(stream);
      connection = connectElementStream(rowModel, rows);
      if (disposed) connection.dispose();
    })();
    return () => {
      disposed = true;
      connection?.dispose();
    };
  }, [openResponseEvents, prompt, rowModel]);

  // NOT `useEffect(() => () => rowModel.dispose())`: StrictMode rehearses an
  // unmount in dev, `useMemo` hands the same model back to the remount, and the
  // grid then renders nothing at all. `useDisposeOnUnmount` defers the disposal
  // by a microtask so a remount can cancel it.
  useDisposeOnUnmount(rowModel);

  return (
    <PretableSurface
      ariaLabel="Streaming chat"
      model={rowModel}
      viewportHeight={320}
    />
  );
}
```

```ts columns.ts
import { createColumnHelper } from "@pretable/core";

export interface ChatRow {
  id: string;
  role: "user" | "assistant";
  content: string;
  tokens: number;
  latencyMs: number;
}

const column = createColumnHelper<ChatRow>();

export const columns = [
  column.accessor("role", { type: "enum", header: "Role" }),
  column.accessor("content", { type: "text", header: "Content" }),
  column.accessor("tokens", { type: "number", header: "Tokens" }),
  column.accessor("latencyMs", { type: "number", header: "Latency" }),
] as const;
```

```ts response-events-to-chat-rows.ts
import type { ChatRow } from "./columns";

export interface ChatResponseEvent {
  readonly type: string;
  readonly delta?: unknown;
  readonly response?: {
    readonly id?: unknown;
    readonly usage?: {
      readonly output_tokens?: unknown;
    } | null;
  };
}

export interface ResponseEventsToChatRowsOptions {
  readonly now?: () => number;
}

function responseId(event: ChatResponseEvent): string | undefined {
  const id = event.response?.id;
  return typeof id === "string" && id.length > 0 ? id : undefined;
}

function outputTokens(event: ChatResponseEvent): number {
  const tokens = event.response?.usage?.output_tokens;
  return typeof tokens === "number" && Number.isFinite(tokens) && tokens >= 0
    ? Math.trunc(tokens)
    : 0;
}

/** Converts Responses API events into complete rows for connectElementStream. */
export async function* responseEventsToChatRows(
  events: AsyncIterable<ChatResponseEvent>,
  options: ResponseEventsToChatRowsOptions = {},
): AsyncIterable<ChatRow> {
  const now = options.now ?? performance.now.bind(performance);
  let id: string | undefined;
  let content = "";
  let startedAt: number | undefined;

  for await (const event of events) {
    if (event.type === "response.created") {
      id = responseId(event);
      content = "";
      startedAt = now();
      continue;
    }

    if (
      event.type === "response.output_text.delta" &&
      typeof event.delta === "string"
    ) {
      content += event.delta;
      continue;
    }

    if (event.type !== "response.completed") continue;

    const completedId = responseId(event) ?? id;
    const completedAt = now();
    const rowContent = content;
    const latencyMs =
      startedAt === undefined
        ? 0
        : Math.max(0, Math.round(completedAt - startedAt));
    id = undefined;
    content = "";
    startedAt = undefined;

    if (completedId === undefined) continue;
    yield {
      id: completedId,
      role: "assistant",
      content: rowContent,
      tokens: outputTokens(event),
      latencyMs,
    };
  }
}
```

```ts scripted-response.ts
import type { OpenChatResponseEvents } from "./ChatGrid";

interface ScriptedResponse {
  readonly id: string;
  readonly chunks: readonly string[];
  readonly outputTokens: number;
}

const SCRIPT: readonly ScriptedResponse[] = [
  {
    id: "resp_1",
    chunks: ["10 incidents ", "over 30 days; ", "6 latency, 4 errors."],
    outputTokens: 15,
  },
  {
    id: "resp_2",
    chunks: ["Top driver: ", "cold-start regressions ", "on the bench worker."],
    outputTokens: 11,
  },
  {
    id: "resp_3",
    chunks: ["Recommend pinning ", "the bench-worker pool size."],
    outputTokens: 8,
  },
];

const DEFAULT_INTERVAL_MS = 220;

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Builds a deterministic stand-in for `openai.responses.stream(...)`. It
 * yields the same lifecycle and text-delta events a real Responses API
 * stream would — `response.created`, then `response.output_text.delta`
 * chunks, then `response.completed` — paced with a delay so rows visibly
 * arrive one at a time instead of all at once.
 *
 * Everything downstream of `openResponseEvents` (the `ChatGrid` prop this
 * satisfies) is unchanged in a real app: swap this generator for a real
 * network call — `openai.responses.stream(...)`, `fromEventSource(...)`, or
 * `parseElementStream` over a decoded `fetch` body — and the row-by-row
 * rendering keeps working exactly as it does here.
 */
export function createScriptedResponseEvents(
  intervalMs: number = DEFAULT_INTERVAL_MS,
): OpenChatResponseEvents {
  return async function* scriptedResponseEvents() {
    for (const response of SCRIPT) {
      yield { type: "response.created", response: { id: response.id } };
      await sleep(intervalMs);
      for (const chunk of response.chunks) {
        yield { type: "response.output_text.delta", delta: chunk };
        await sleep(intervalMs);
      }
      yield {
        type: "response.completed",
        response: {
          id: response.id,
          usage: { output_tokens: response.outputTokens },
        },
      };
      await sleep(intervalMs);
    }
  };
}

/** The pacing used by the live demo. */
export const scriptedResponseEvents: OpenChatResponseEvents =
  createScriptedResponseEvents();
```
