DEV Community

OpenTiny
OpenTiny

Posted on

Bloat-Free Extensibility: The Evolution of TinyRobot — From a Simple AI Input Box to a Fully-Featured AI Input Console

Introduction

At the very beginning of AI chat development, an input box plus a send button was all you needed. But as AI capabilities expanded to support file uploads, voice input, LLM switching, web search, deep reasoning and more, the bottom input area gradually turned into an all-in-one AI console.

This article uses TinyRobot’s Sender component as an example. We break down how the component’s scope expanded alongside evolving business requirements, and discuss architectural improvements that boost component flexibility and adaptability.

I. Sender Component Bloats As Requirements Pile Up

Stage 1: Minimalist Basic AI Input Box

In early project phases, requirements were simple — only core chat functionality was needed:

  1. Type messages and send with one click
  2. Interrupt AI output mid-generation
  3. Clear input content in one tap, with maximum character limit to avoid overly long text


Minimal sample code (size & debug configurations omitted):

<TrSender
  v-model="message"
  placeholder="Send a message to AI..."
  :loading="loading"
  clearable
  show-word-limit
  :max-length="2000"
  @submit="sendMessage"
  @cancel="stopGeneration"
/>
Enter fullscreen mode Exit fullscreen mode

At this stage, the Sender only handled text input, character counting, and content clearing. The send button automatically switches to a stop button when the AI streams responses — nothing more.

Stage 2: Feature Explosion — Input Box Becomes AI Console

As business iteration accelerated, plain text chat was no longer sufficient. A full suite of auxiliary capabilities was added:

  • File & image upload with preview (TrUploadButton, TrAttachments)
  • Voice-to-text input (TrVoiceButton)
  • Dynamic LLM model switching (ModelSelector)
  • One-click deep reasoning mode (DeepThinking)
  • Real-time web search for up-to-date information (WebSearch)


All features are embedded via component slots. Key code snippet extracted from live demos:

<!-- Core TinyRobot Sender Component -->
<TrSender
  v-model="message"
  mode="multiple"
  :has-external-content="attachments.length > 0"
>
  <!-- Top Slot: Attachment Display -->
  <template #header v-if="attachments.length">
    <TrAttachments v-model:items="attachments" :actions="[]" variant="card" wrap />
  </template>

  <!-- Bottom Left Slot: Model & Feature Toggles -->
  <template #footer>
    <ModelSelector v-model="model" />
    <DeepThinking />
    <WebSearch />
  </template>

  <!-- Bottom Right Slot: Upload & Voice Buttons -->
  <template #footer-right>
    <TrUploadButton @select="addFiles" />
    <TrVoiceButton />
  </template>
</TrSender>
Enter fullscreen mode Exit fullscreen mode

While features were split into standalone sub-components embedded via slots (to avoid bloated props lists and enable reuse across pages), critical limitations remained:

  1. Fixed slot positions: Upload/voice buttons cannot be rearranged; DOM hierarchy is locked inside the Sender.
  2. Manual state sync: Developers must manually pass the has-external-content flag to notify the Sender of attachments.
  3. Constant API expansion: New features require new slots and props as functionality grows.

In short: Although TinyRobot split discrete sub-components, the overall page layout and element positions remained controlled by the Sender, limiting full customization for business scenarios.

Stage 3: Severe UI Divergence Between PC & Mobile

Desktop screens have ample space to display all buttons in full text, while mobile interfaces demand compact, condensed layouts:

  • PC: Full text buttons for model selection, reasoning and search; upload/voice buttons fully visible
  • Mobile: Only icons for core features; attachments hidden inside an expandable menu; tap microphone to switch to full-screen long-press voice input, tap keyboard icon to revert to text typing


This forced heavy conditional logic with repeated isMobile checks:

<TrSender v-model="message" mode="multiple">
  <!-- Replace text input with voice panel on mobile -->
  <template #content v-if="isMobile && voiceMode">
    <VoiceHoldButton />
  </template>

  <!-- Differentiated Footer Toolbar for PC/Mobile -->
  <template #footer>
    <template v-if="isMobile">
      <MoreActions>
        <TrUploadButton @select="addFiles" />
      </MoreActions>
      <ModelSelector v-model="model" compact />
      <DeepThinking icon-only />
      <WebSearch icon-only />
    </template>
    <template v-else>
      <ModelSelector v-model="model" />
      <DeepThinking />
      <WebSearch />
    </template>
  </template>

  <!-- Right-Side Action Bar Split by Device -->
  <template #footer-right>
    <template v-if="isMobile">
      <InputModeToggle :mode="voiceMode ? 'keyboard' : 'voice'" @click="voiceMode = !voiceMode" />
    </template>
    <template v-else>
      <TrUploadButton @select="addFiles" />
      <TrVoiceButton />
    </template>
  </template>
</TrSender>
Enter fullscreen mode Exit fullscreen mode

Summary of Early Pain Points

Stage 2 already introduced rigid UI structures and tedious manual state synchronization. Mobile adaptation in Stage 3 amplified these issues, resulting in bloated, hard-to-maintain conditional code. Three core problems needed a fundamental refactor:

  1. Share unified state & logic across PC/mobile without duplicate input/submit implementations
  2. Auto-detect attachments and external content without manual boolean flags
  3. Avoid continuous additions of props/slots when supporting new features or device types

The following sections detail architectural refactors that resolve these three core limitations.

II. Separate Logic & Layout: Shared Core Capabilities, Independent Terminal UI

The original Sender tightly coupled state, interaction logic and DOM layout. The key improvement decouples them:

  • SenderRoot: Acts as the core capability container, solely responsible for input, submission, loading and underlying logic
  • Layout structure, button positioning and visual design are fully controlled by business developers

PC and mobile share identical core functionality (text input, message submission, stream interruption, attachment management, model switching, reasoning/search toggles, text/voice toggle) — the only difference is layout arrangement due to screen size constraints.

Desktop Layout (Sufficient Screen Space, All Buttons Fully Visible)

<SenderRoot v-model="message" @submit="submit">
  <SenderAttachments v-model:items="attachments" />
  <SenderEditor />
  <footer class="desktop-toolbar">
    <ModelSelector v-model="model" />
    <DeepThinking />
    <WebSearch />
    <div class="desktop-actions">
      <SenderUploadButton @select="addFiles" />
      <SenderVoiceButton />
      <SenderSubmit />
    </div>
  </footer>
</SenderRoot>
Enter fullscreen mode Exit fullscreen mode

Mobile Layout (Compact Space, Foldable Low-Frequency Features)

<SenderRoot v-model="message" @submit="submit">
  <SenderAttachments v-model:items />
  <!-- Toggle between text editor and voice long-press panel -->
  <SenderEditor v-if="inputMode === 'text'" />
  <VoiceHoldButton v-else />

  <footer class="mobile-toolbar">
    <MoreActionsTrigger />
    <ModelSelector v-model="model" compact />
    <DeepThinking icon-only />
    <WebSearch icon-only />
    <InputModeToggle />
    <SenderSubmit />
  </footer>

  <!-- Expandable upload menu -->
  <MoreActions v-if="moreActionsOpen">
    <SenderUploadButton @select="addFiles" />
  </MoreActions>
</SenderRoot>
Enter fullscreen mode Exit fullscreen mode

Cross-Device Implementation

For documentation demonstration, both layouts can coexist with device detection logic; in production, render only the matching layout inside SenderRoot:

<SenderRoot v-model="message" :loading="loading" @submit="submit">
  <template v-if="isDesktop">
    <!-- Full desktop layout code above -->
  </template>
  <template v-else>
    <!-- Compact mobile layout code above -->
  </template>
</SenderRoot>
Enter fullscreen mode Exit fullscreen mode

Switching devices only swaps DOM markup — message text, attachments, selected LLM and loading state remain unchanged and will not reset. Temporary UI states (panel expansion) reset when components unmount.

Key advantage: No new props or slots required for multi-terminal support; identical underlying logic with fully customizable layout arrangements.

Intermediate Summary

This composable architecture resolves two original pain points:

  1. Reuse identical state & logic across PC/mobile with fully customizable layouts
  2. No breaking API changes or new slots when adapting to new devices

However, a new issue emerged: If PC/mobile layouts are extracted into standalone components, attachment and model state must be passed down layer by layer, leading to bloated props lists again. Further decoupling via Context is required.

III. Decouple via Layered Context: Eliminate Prop Drilling

While separate desktop/mobile layouts clean up code, extracting them into independent DesktopSenderLayout and MobileSenderLayout introduces heavy prop drilling for attachments, selected models and disabled states:

<SenderRoot v-model="message" :loading="loading" @submit="submit">
  <DesktopSenderLayout
    v-if="isDesktop"
    v-model:attachments="attachments"
    v-model:model="model"
    :disabled="disabled"
  />
  <MobileSenderLayout
    v-else
    v-model:attachments="attachments"
    v-model:model="model"
    :disabled="disabled"
  />
</SenderRoot>
Enter fullscreen mode Exit fullscreen mode

As features expand, the layout components accumulate dozens of props. The solution splits state into two Context layers: universal core capabilities and domain-specific business data, shared across all child components without prop drilling.

Layer 1: Global Sender Context for Universal Core Logic

SenderRoot injects a global context exposing core chat functionality (text binding, loading, submission, clearing, stream cancellation) accessible to all descendant components:

// Inside SenderRoot
provide(senderContextKey, {
  message,
  loading,
  disabled,
  maxLength,
  canSubmit,
  clear,
  submit,
  cancel,
})
Enter fullscreen mode Exit fullscreen mode

The SenderEditor input component no longer requires bound values passed from parents; it directly consumes the context for text binding, keyboard submission and line break logic:

// Inside SenderEditor
const { message, disabled, maxLength, submit } = useSenderContext()

const handleInput = (event: Event) => {
  message.value = (event.target as HTMLTextAreaElement).value
}

const handleKeydown = (event: KeyboardEvent) => {
  if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
  event.preventDefault()
  submit()
}
Enter fullscreen mode Exit fullscreen mode

SenderEditor can be placed anywhere inside the SenderRoot DOM tree, with no fixed slot constraints. Only presentation-specific props such as placeholder remain. Layout components no longer need to forward core state, eliminating massive prop lists. Model selection and reasoning toggles remain business logic and are not injected into the global context to avoid bloating SenderRoot.

Layer 2: Domain-specific Context, Independent Closed-loop Management of Attachment States

The SenderRoot Context eliminates the transparent transmission of common capabilities such as input, loading, and submission, but attachments still need to be shared between the upload button, the attachment list, and the two Layouts. If external Refs are still used, each Layout would need to receive the same attachment Props and events:

This is because SenderAttachments is the consumer of the attachments, while SenderUploadButton is the producer of the attachments. When they are used as sibling nodes, the business logic needs to connect them using Refs and events.


The attachmentsRef and addFiles in this context actually belong to the attachment domain. Currently, they are held by the business layer solely to connect the producers and consumers of attachments. If each business logic repeatedly writes this layer of wiring, composable components still do not encapsulate a complete collaborative attachment capability.

Therefore, a SenderAttachmentProvider can be introduced. It uniformly holds the attachment state and operation methods and provides them to descendant components through the Context. The Provider is placed on the smallest common ancestor of the producer and consumer, establishing only the data scope without dictating their DOM positions.

<SenderRoot v-model="message" @submit="submit">
  <SenderAttachmentProvider>
    <SenderAttachments />
    <SenderEditor />
    <footer>
      <SenderUploadButton />
      <SenderSubmit />
    </footer>
  </SenderAttachmentProvider>
</SenderRoot>
Enter fullscreen mode Exit fullscreen mode

Attachment context interface:

interface SenderAttachmentContext {
  items: Ref<Attachment[]>
  addFiles: (files: File[]) => void
  remove: (id: string) => void
  clear: () => void
}
Enter fullscreen mode Exit fullscreen mode
  • Upload buttons call addFiles to append files
  • Attachment lists read items and trigger remove for deletion
  • All attachment state logic is encapsulated within the provider, no manual state sync required for business code

Unified Submission Registry Protocol

Early designs required manual has-external-content boolean flags to notify the Sender of attachments. We introduced a content registration protocol where all external data sources self-register to SenderRoot:

interface SenderContentRegistry {
  registerContent<T>(source: string, payload: MaybeRefOrGetter<T>): () => void
}
Enter fullscreen mode Exit fullscreen mode
  • source: Data type identifier (attachments, knowledge base, etc.)
  • payload: Complete structured data
  • Return: Cleanup function auto-unregistered when components unmount

The attachment provider automatically registers attachment data:

const unregister = registerContent('attachments', items)
Enter fullscreen mode Exit fullscreen mode

On submission, the submit event returns all registered external data alongside text content in one payload:

const submit = ({ text, contents }: SenderSubmission) => {
  const attachments = contents.find(({ source }) => source === 'attachments')?.payload
}
Enter fullscreen mode Exit fullscreen mode

This fully resolves the three original core challenges:

  1. Unified state & logic reused across PC/mobile with fully customizable layouts
  2. Attachments auto-included in submission logic without manual boolean flags
  3. New data types only require new source identifiers, no breaking changes to Sender APIs

Final clean layout code after layered context refactor:

<SenderRoot v-model="message" :loading="loading" @submit="submit">
  <SenderAttachmentProvider>
    <DesktopSenderLayout v-if="isDesktop" />
    <MobileSenderLayout v-else />
  </SenderAttachmentProvider>
</SenderRoot>
Enter fullscreen mode Exit fullscreen mode

Model selection and other business modules can still pass props directly when appropriate. General rule: Share data across multiple components via dedicated Provider; independent simple components use direct props. State ownership belongs to the nearest common ancestor of all consuming components.

IV. Two-Tier API: Balance Flexibility & Development Speed

The fully composable atomic architecture delivers maximum layout freedom, but requires assembling dozens of components for simple use cases — similar to headless UI design patterns (e.g. shadcn/ui). TinyRobot implements a dual-layer API system:

  1. Low-Level Composable Atomic Components: SenderRoot, SenderEditor, SenderAttachments etc. For deeply customized, highly differentiated multi-terminal scenarios with full layout control.
  2. High-Level Wrapped Preset Component: The original Sender component with built-in standard layouts for rapid out-of-box development.

Both layers share identical underlying core logic with no separate codebases. Simple projects use the wrapped component for fast integration; complex cross-terminal projects can drop down to atomic building blocks for unlimited customization.

Core principle: Preset components accelerate rapid development, while composable atomic components enable unlimited structural modification.

About OpenTiny NEXT

OpenTiny NEXT is an enterprise-grade intelligent frontend solution built on Generative UI and WebMCP core technologies. It delivers intelligent upgrades for legacy products including the TinyVue component library and TinyEngine low-code engine, while launching Agent-native products such as frontend NEXT-SDKs, AI Extension, TinyRobot AI Assistant and GenUI. The platform enables AI to interpret user intentions and complete tasks autonomously, accelerating enterprise intelligent transformation.

Join the OpenTiny Open Source Community

WeChat Assistant: opentiny-official

If you wish to contribute, look for issues tagged good first issue within the repository. Feel free to leave comments with any questions or feedback!

Top comments (0)