Skip to main content

Document Improver

A two-panel interface where users paste rough text on the left and watch a polished version stream into the right panel. Tokens appear as the model generates them—users see immediate progress rather than a loading spinner.

Server Code

import { AgenticSystem, field, action, stream } from 'idyllic';

export default class DocImprover extends AgenticSystem {
  @field content = '';
  @field improved = stream<string>('');
  @field status: 'idle' | 'improving' = 'idle';

  @action()
  async setContent(text: string) {
    this.content = text;
  }

  @action()
  async improve() {
    this.status = 'improving';
    this.improved.reset();

    for await (const chunk of ai.stream(
      `Improve this text, making it clearer and more professional:\n\n${this.content}`
    )) {
      this.improved.append(chunk);
    }

    this.improved.complete();
    this.status = 'idle';
  }
}
The content field holds the input as a plain string. The improved field uses stream<string> because it accumulates progressively during generation—each append() broadcasts just that chunk, creating the token-by-token effect. The status field drives loading states. While 'improving', the UI disables the button and shows a cursor.

Client Code

import { useSystem } from '@idyllic/react';
import type { DocImprover } from '../systems/DocImprover';

export default function Editor() {
  const { content, improved, status, setContent, improve } = useSystem<DocImprover>();

  return (
    <div className="grid grid-cols-2 gap-4 p-4 h-screen">
      <div className="flex flex-col">
        <h2 className="text-lg font-medium mb-2">Original</h2>
        <textarea
          value={content}
          onChange={e => setContent(e.target.value)}
          className="flex-1 p-3 border rounded font-mono"
          placeholder="Paste your text here..."
        />
        <button
          onClick={improve}
          disabled={status === 'improving'}
          className="mt-2 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
        >
          {status === 'improving' ? 'Improving...' : 'Improve'}
        </button>
      </div>

      <div className="flex flex-col">
        <h2 className="text-lg font-medium mb-2">Improved</h2>
        <div className="flex-1 p-3 border rounded bg-gray-50 whitespace-pre-wrap">
          {improved.current}
          {status === 'improving' && <span className="animate-pulse">|</span>}
        </div>
      </div>
    </div>
  );
}
The useSystem hook returns reactive state that updates whenever the server broadcasts changes. The blinking cursor appears while streaming and disappears when complete.

Running the Example

npx idyllic dev
Open http://localhost:3000. Paste text, click Improve, watch it stream. Open a second tab—both display the same streaming output since they share state.

Variations

Revision History

Track previous improvements before starting each generation:
@field revisions: string[] = [];

@action()
async improve() {
  if (this.improved.current) {
    this.revisions.push(this.improved.current);
  }
  // ... rest of improve logic
}

Multiple Styles

Parameterize the action for different tones:
@action()
async improve(style: 'professional' | 'casual' | 'concise') {
  const prompts = {
    professional: 'Make this more professional and formal',
    casual: 'Make this friendlier and more conversational',
    concise: 'Make this shorter without losing meaning',
  };

  for await (const chunk of ai.stream(`${prompts[style]}:\n\n${this.content}`)) {
    this.improved.append(chunk);
  }
  // ...
}
TypeScript ensures the client passes a valid style argument.