Skip to content

Open Source ChatGPT Clone UI, Assembled with aparté

The whole application, assembled from what ships: the shell grid and the header are recipes (classes), the sidebar and the conversation list are elements (they have behaviour — the drawer, the search, the selection), and the chat is <aparte-chat>. It talks to a scripted model here — no key, no network — so everything you see run is real except the model: the loop, the transcript, the tool call, the attachments.

Width Open in a tab
Type haiku, table, code or weather. Narrow the frame and the sidebar becomes a drawer behind the toggle. Open it in a tab for the full screen.
<!-- The whole application, assembled from what ships: the shell grid and the header
are recipes, the sidebar and the conversation list are elements, the chat is the
chat. This frame talks to a SCRIPTED model (no key, no network) - type "haiku",
"table", "code" or "weather" and watch the loop, the transcript and the tool call
run for real. Everything but the model is what your users would get.
The sidebar keeps the shell's own 48rem breakpoint: below 768px it is a drawer
behind the toggle - the frame is a window of its own, so narrow the page (or press
375) and watch it fold, exactly as it would on a phone. -->
<div class="aparte-app-shell" style="height: 42rem">
<aparte-sidebar>
<div class="aparte-sidebar__header">
<span class="aparte-sidebar__brand">(aparté)</span>
<button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New conversation">
<aparte-icon name="edit"></aparte-icon>
</button>
</div>
<div class="aparte-sidebar__search aparte-field-group">
<input class="aparte-field aparte-field--sm" type="search" placeholder="Search conversations" aria-label="Search conversations" data-aparte-sidebar-search>
</div>
<div class="aparte-sidebar__body">
<aparte-conversation-list active-id="c1"></aparte-conversation-list>
</div>
<div class="aparte-sidebar__footer">
<span class="aparte-avatar aparte-avatar--sm">P</span> Paul
</div>
</aparte-sidebar>
<header class="aparte-app-header">
<button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button>
<span class="aparte-app-header__title">What is aparté?</span>
<div class="aparte-app-header__actions"><span class="aparte-tag">scripted model</span></div>
</header>
<main class="aparte-app-shell__main">
<!-- overlay-composer: the transcript's scroll surface spans the whole column and the
composer floats over it, so the scrollbar runs to the bottom edge - the anatomy
ChatGPT has, one attribute here. -->
<aparte-chat attachments overlay-composer>
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="What is aparté, in one sentence?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="A chat UI in Web Components with the agent loop inside - streaming markdown, tool calls a person approves, typed questions and branching, with zero dependencies. This whole screen is it: ask me for a haiku, a table, some code, or the weather."></aparte-chat-bubble>
</aparte-chat-viewport>
<aparte-composer>
<div class="aparte-composer-shell">
<aparte-composer-attachments></aparte-composer-attachments>
<div class="aparte-composer-row">
<aparte-composer-add-attachment></aparte-composer-add-attachment>
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
</aparte-chat>
</main>
</div>
<script>
const day = 864e5;
const list = document.querySelector('aparte-conversation-list');
list.conversations = [
{ id: 'c1', title: 'What is aparté?', updatedAt: Date.now() },
{ id: 'c2', title: 'Approve a tool call', updatedAt: Date.now() - day },
{ id: 'c3', title: 'Branch a reply', updatedAt: Date.now() - 3 * day },
{ id: 'c4', title: 'Theme it in three variables', updatedAt: Date.now() - 12 * day },
];
// The header title follows the selected conversation - one listener, no framework.
list.addEventListener('aparte-conversation-select', (e) => {
const picked = list.conversations.find((c) => c.id === e.detail.id);
if (picked) document.querySelector('.aparte-app-header__title').textContent = picked.title;
});
</script>
  • .aparte-app-shell — a two-column grid: the sidebar spans both rows, the header and the main area take the second column. Give it a height and it fills it.
  • <aparte-sidebar> — collapsible, a drawer under its breakpoint, and it listens for data-aparte-sidebar-toggle and data-aparte-sidebar-search on its own, so the header’s button and the search field need no script.
  • <aparte-conversation-list> — takes a conversations array, marks active-id, and dispatches aparte-conversation-select. The header title above follows it in one listener.
  • <aparte-chat attachments> — the chat with the file picker; the host consumes the files (an AparteClient does).

Three moves turn this into your application:

  1. The model. Replace the scripted provider with yours — an OpenAI-compatible endpoint (@aparte/provider-openai-compat, presets for OpenAI, Mistral, OpenRouter, Ollama, LM Studio), the Vercel AI SDK, or a model in the browser (@aparte/provider-transformers) — or keep the key on your server with the backend transport.
  2. The conversations. Feed conversations from your store and persist with the conversation manager.
  3. The look. It is all CSS variables and light DOM — see theming.

The recipe behind the shell, and its mobile behaviour, are on the app shell guide; the layouts a chat can take — split, feed, side panel — on the layout guide.