betterbot/static/index.html
Andre K e68c84424f
Some checks failed
Deploy BetterBot / deploy (push) Failing after 3s
Deploy BetterBot / notify (push) Successful in 3s
feat: fork from CodeAnywhere framework
Replace standalone Telegram bot with full CodeAnywhere framework fork.
BetterBot shares all framework code and customizes only:
- instance.py: BetterBot identity, system prompt, feature flags
- tools/site_editing/: list_files, read_file, write_file with auto git push
- .env: model defaults and site directory paths
- compose/: Docker setup with betterlifesg + memoraiz mounts
- deploy script: RackNerd with Infisical secrets
2026-04-19 08:01:27 +08:00

2437 lines
No EOL
84 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CodeAnywhere</title>
<style>
:root {
color-scheme: dark;
--bg: #0b1220;
--panel: rgba(17, 27, 47, 0.9);
--panel-strong: rgba(22, 36, 61, 0.92);
--line: rgba(151, 180, 255, 0.18);
--text: #eaf1ff;
--muted: #9fb4d9;
--accent: #80b3ff;
--accent-strong: #4d8dff;
--warning-line: rgba(255, 157, 181, 0.35);
--warning-text: #ffd6de;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
--radius: 20px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top right, rgba(128, 179, 255, 0.16), transparent 26%),
linear-gradient(180deg, #07101d 0%, #0b1220 100%);
color: var(--text);
font-family: "Segoe UI", "IBM Plex Sans", sans-serif;
}
body {
padding: 16px;
overflow-x: hidden;
}
.hidden {
display: none !important;
}
.shell {
min-height: calc(100vh - 32px);
display: grid;
gap: 12px;
}
.banner,
.panel,
.chat-shell,
.debug-dock,
.native-chat {
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.banner {
position: fixed;
top: 16px;
right: 16px;
z-index: 20;
width: min(320px, calc(100vw - 32px));
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel-strong);
color: var(--accent);
font-size: 14px;
}
.panel {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--warning-line);
background: rgba(35, 24, 28, 0.92);
color: var(--warning-text);
}
.panel h2 {
margin: 0 0 8px;
font-size: 16px;
}
.panel p,
.panel li {
margin: 0 0 8px;
line-height: 1.5;
}
.panel ul {
margin: 0;
padding-left: 18px;
}
.panel code,
.meta,
pre {
font-family: "Cascadia Code", Consolas, monospace;
}
.chat-shell {
position: relative;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 12px;
min-height: 78vh;
}
body.sidebar-collapsed .chat-shell {
grid-template-columns: 72px minmax(0, 1fr);
}
.sidebar,
.conversation {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
overflow: hidden;
}
.sidebar {
display: grid;
grid-template-rows: auto auto 1fr;
transition: transform 0.18s ease, width 0.18s ease;
}
.sidebar-header,
.conversation-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px;
border-bottom: 1px solid var(--line);
background: var(--panel-strong);
}
.sidebar-headline {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.sidebar-copy {
min-width: 0;
}
.eyebrow {
margin: 0 0 6px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
font-size: 22px;
line-height: 1.1;
}
.controls,
.thread-tools {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.controls {
justify-content: flex-end;
}
.conversation-heading {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1 1 240px;
}
.mobile-sidebar-toggle {
display: none;
align-items: center;
justify-content: center;
white-space: nowrap;
}
button,
input,
textarea {
font: inherit;
}
button {
border: none;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
color: #03101f;
padding: 10px 14px;
font-weight: 700;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
font-weight: 600;
}
.icon-button {
min-width: 44px;
padding: 10px 12px;
}
.model-panel {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
display: grid;
gap: 10px;
}
.model-panel label,
.composer label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
input,
textarea {
width: 100%;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(8, 15, 28, 0.9);
color: var(--text);
padding: 12px 14px;
}
textarea {
min-height: 124px;
resize: vertical;
}
.threads {
overflow-y: auto;
padding: 8px;
display: grid;
gap: 6px;
align-content: start;
}
.thread-card {
width: 100%;
height: 64px;
border: 1px solid transparent;
border-radius: 14px;
background: rgba(8, 15, 28, 0.82);
color: var(--text);
padding: 8px 10px;
text-align: left;
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
align-items: center;
gap: 8px;
overflow: hidden;
}
.thread-card.active {
border-color: rgba(128, 179, 255, 0.45);
background: rgba(21, 36, 63, 0.98);
}
.thread-badge {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 10px;
background: rgba(128, 179, 255, 0.14);
color: var(--accent);
font-weight: 700;
font-size: 13px;
flex-shrink: 0;
}
.thread-card.active .thread-badge {
background: var(--accent);
color: #03101f;
}
.thread-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.thread-title-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
line-height: 1.3;
}
.meta {
color: var(--muted);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
body.sidebar-collapsed .sidebar-copy,
body.sidebar-collapsed .model-panel,
body.sidebar-collapsed .thread-copy {
display: none;
}
body.sidebar-collapsed #new-chat-btn {
min-width: 44px;
padding-left: 0;
padding-right: 0;
}
body.sidebar-collapsed .thread-card {
grid-template-columns: 1fr;
justify-items: center;
height: 52px;
padding: 8px 6px;
}
body.sidebar-collapsed #magic-rename-all-btn {
min-width: 44px;
padding-left: 0;
padding-right: 0;
}
.conversation {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 78vh;
}
.messages {
padding: 20px;
overflow-y: auto;
display: grid;
gap: 14px;
align-content: start;
background:
linear-gradient(180deg, rgba(9, 16, 29, 0.9), rgba(15, 24, 40, 0.9)),
radial-gradient(circle at top left, rgba(128, 179, 255, 0.08), transparent 30%);
}
.message {
max-width: min(760px, 100%);
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(17, 27, 47, 0.88);
}
.message.user {
justify-self: end;
background: rgba(77, 141, 255, 0.18);
border-color: rgba(128, 179, 255, 0.34);
}
.message.assistant {
justify-self: start;
}
.message.pending {
border-style: dashed;
background: rgba(12, 24, 43, 0.9);
}
.message.pending .message-body {
color: var(--accent);
}
.message.trace-only {
background: rgba(11, 19, 34, 0.94);
}
.message-label {
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.message-body {
color: var(--text);
line-height: 1.6;
word-break: break-word;
font-size: 15px;
}
.message-body.is-plain {
white-space: pre-wrap;
font-family: "Cascadia Code", Consolas, monospace;
}
.message-images {
display: grid;
gap: 10px;
margin-top: 12px;
}
.message-image {
margin: 0;
display: grid;
gap: 8px;
}
.message-image img {
display: block;
width: 100%;
max-width: min(460px, 100%);
border-radius: 14px;
border: 1px solid rgba(128, 179, 255, 0.2);
background: rgba(6, 12, 22, 0.92);
}
.message-image figcaption {
color: var(--muted);
font-size: 12px;
}
.trace-stack {
display: grid;
gap: 10px;
margin-top: 12px;
}
.trace-bubble {
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(8, 15, 28, 0.9);
overflow: hidden;
}
.trace-bubble summary {
list-style: none;
cursor: pointer;
padding: 10px 12px;
display: grid;
gap: 4px;
}
.trace-bubble summary::-webkit-details-marker {
display: none;
}
.trace-kicker {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.trace-title {
color: var(--text);
font-size: 13px;
font-weight: 700;
line-height: 1.4;
}
.trace-summary {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
word-break: break-word;
}
.trace-body {
margin: 0;
padding: 0 12px 12px;
border-top: 1px solid var(--line);
white-space: pre-wrap;
word-break: break-word;
color: #c8d8f0;
font-size: 12px;
line-height: 1.6;
font-family: "Cascadia Code", Consolas, monospace;
}
.trace-bubble.subagent {
border-color: rgba(128, 179, 255, 0.28);
}
.trace-bubble.tool_call {
border-color: rgba(94, 196, 255, 0.28);
}
.trace-bubble.tool_output {
border-color: rgba(120, 214, 163, 0.28);
}
.trace-collapsed-summary {
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(8, 15, 28, 0.9);
overflow: hidden;
}
.trace-counts-line {
list-style: none;
cursor: pointer;
padding: 10px 14px;
color: var(--muted);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
user-select: none;
}
.trace-counts-line::-webkit-details-marker {
display: none;
}
.trace-collapsed-summary[open] .trace-counts-line {
border-bottom: 1px solid var(--line);
color: var(--accent);
}
.trace-expanded-items {
padding: 8px;
display: grid;
gap: 8px;
}
.trace-detail-section {
margin-bottom: 8px;
}
.trace-detail-label {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 4px;
}
.markdown-body> :first-child {
margin-top: 0;
}
.markdown-body> :last-child {
margin-bottom: 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 0 0 12px;
font-size: inherit;
font-weight: 700;
}
.markdown-body p,
.markdown-body ul,
.markdown-body ol,
.markdown-body pre,
.markdown-body blockquote,
.markdown-body table,
.markdown-body hr {
margin: 0 0 12px;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 22px;
}
.markdown-body li+li {
margin-top: 4px;
}
.markdown-body a {
color: var(--accent);
}
.markdown-body blockquote {
padding-left: 12px;
border-left: 3px solid rgba(128, 179, 255, 0.35);
color: var(--muted);
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--line);
}
.markdown-body code,
.markdown-body pre {
font-family: "Cascadia Code", Consolas, monospace;
}
.markdown-body :not(pre)>code {
padding: 0.16em 0.38em;
border-radius: 8px;
background: rgba(128, 179, 255, 0.12);
font-size: 0.94em;
}
.markdown-body pre {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(6, 12, 22, 0.92);
overflow: auto;
}
.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 0.94em;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--line);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.markdown-body img {
max-width: 100%;
border-radius: 12px;
}
.empty-state {
align-self: center;
justify-self: center;
max-width: 460px;
padding: 24px;
text-align: center;
color: var(--muted);
line-height: 1.6;
}
.composer {
display: grid;
gap: 12px;
padding: 18px;
border-top: 1px solid var(--line);
background: var(--panel-strong);
}
.composer-uploads {
display: grid;
gap: 10px;
}
.composer-upload-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.composer-upload {
min-width: 0;
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(8, 15, 28, 0.88);
}
.composer-upload img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(128, 179, 255, 0.2);
background: rgba(6, 12, 22, 0.92);
}
.composer-upload-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.composer-upload-title {
color: var(--text);
font-size: 13px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-upload-meta {
color: var(--muted);
font-size: 12px;
}
.composer-secondary-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.composer-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
color: var(--muted);
font-size: 13px;
}
.native-chat {
display: block;
width: 100%;
min-height: 78vh;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--line);
background: var(--panel);
}
.debug-dock {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 25;
width: fit-content;
max-width: min(420px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(10, 16, 28, 0.74);
opacity: 0.76;
transition: opacity 0.18s ease;
}
.debug-dock:hover,
.debug-dock[open] {
opacity: 1;
}
.debug-dock summary {
cursor: pointer;
padding: 8px 12px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.debug-dock pre {
margin: 0;
padding: 0 12px 12px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: #b7c9e8;
font-size: 12px;
}
.sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 40;
border: none;
background: rgba(3, 8, 16, 0.56);
backdrop-filter: blur(6px);
}
body.mobile-sidebar-open {
overflow: hidden;
}
@media (max-width: 1240px) {
.sidebar-header,
.conversation-header {
flex-wrap: wrap;
align-items: flex-start;
}
.controls,
.thread-tools {
width: 100%;
justify-content: flex-start;
}
.controls button,
.thread-tools button {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 900px) {
body {
padding: 0;
}
.chat-shell {
grid-template-columns: 1fr;
min-height: 100dvh;
gap: 0;
}
.sidebar {
position: fixed;
top: 12px;
bottom: 12px;
left: 12px;
width: min(260px, calc(100vw - 56px));
max-width: calc(100vw - 56px);
z-index: 50;
border-radius: 22px;
transition: transform 0.2s ease, opacity 0.2s ease;
}
body.sidebar-collapsed .sidebar {
transform: translateX(calc(-100% - 18px));
opacity: 0;
pointer-events: none;
}
.conversation {
min-height: 100dvh;
border-radius: 0;
border-left: none;
border-right: none;
border-bottom: none;
}
.conversation-header {
position: sticky;
top: 0;
z-index: 10;
}
.sidebar-header,
.conversation-header,
.composer,
.model-panel {
padding: 14px;
}
.messages {
padding: 16px 14px 132px;
}
.controls,
.thread-tools,
.composer-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.controls button,
.thread-tools button,
.composer-actions button {
flex: 1;
min-width: 0;
}
.composer-upload {
grid-template-columns: 56px minmax(0, 1fr);
}
.composer-upload img {
width: 56px;
height: 56px;
}
.composer-upload button {
grid-column: 1 / -1;
}
.mobile-sidebar-toggle {
display: inline-flex;
flex: 0 0 auto;
}
.debug-dock {
right: 12px;
bottom: 12px;
max-width: calc(100vw - 24px);
}
.banner {
top: 12px;
left: 12px;
right: 12px;
width: auto;
}
.empty-state {
padding: 18px;
}
}
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js" async></script>
</head>
<body>
<div class="shell">
<div id="status" class="banner hidden"></div>
<div id="error-panel" class="panel hidden">
<h2 id="error-title">Something needs attention</h2>
<p id="error-message"></p>
<ul>
<li>The workspace UI stays available even if native ChatKit fails.</li>
<li>Default thread titles now come from the first non-command line of your first prompt.</li>
<li>Use <code>?native=1</code> only if you explicitly want the OpenAI ChatKit widget.</li>
</ul>
</div>
<button id="sidebar-backdrop" class="sidebar-backdrop hidden" type="button"
aria-label="Close thread list"></button>
<div id="workspace-app" class="chat-shell">
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-headline">
<button id="sidebar-toggle-btn" class="ghost icon-button" type="button"
aria-label="Collapse thread list">
<span id="sidebar-toggle-icon">&lt;</span>
</button>
<div class="sidebar-copy">
<p class="eyebrow">Threads</p>
<h1>CodeAnywhere</h1>
</div>
</div>
<div class="controls">
<button id="magic-rename-all-btn" class="ghost" type="button">Magic rename all</button>
<button id="new-chat-btn" type="button">New chat</button>
</div>
</div>
<div class="model-panel">
<label>
Active model
<input id="model-input" type="text" placeholder="gpt-5.4-mini">
</label>
<button id="save-model-btn" type="button">Save model</button>
<div id="model-hint" class="meta">Your first prompt creates the thread and uses this model.</div>
</div>
<div id="threads" class="threads"></div>
</aside>
<section class="conversation">
<div class="conversation-header">
<div class="conversation-heading">
<button id="mobile-sidebar-toggle-btn" class="ghost mobile-sidebar-toggle"
type="button">Threads</button>
<div>
<p class="eyebrow">Workspace chat</p>
<h2 id="thread-title">Start a chat</h2>
</div>
</div>
<div class="thread-tools">
<button id="magic-rename-btn" class="ghost" type="button">Magic rename</button>
<button id="rename-chat-btn" class="ghost" type="button">Rename</button>
<button id="delete-chat-btn" class="ghost" type="button">Delete</button>
</div>
</div>
<div id="messages" class="messages"></div>
<form id="composer-form" class="composer">
<label>
Prompt
<textarea id="composer-input"
placeholder="Ask anything. The first line becomes the thread title."></textarea>
</label>
<input id="composer-image-input" class="hidden" type="file" accept="image/*" multiple>
<div id="composer-uploads" class="composer-uploads hidden"></div>
<div class="composer-actions">
<div class="composer-secondary-actions">
<button id="add-image-btn" class="ghost" type="button">Add image</button>
<span id="composer-hint">Shift+Enter for a newline. Press Enter to send.</span>
</div>
<button id="send-btn" type="submit">Send</button>
</div>
</form>
</section>
</div>
<openai-chatkit id="chat" class="native-chat hidden"></openai-chatkit>
<details id="debug-dock" class="debug-dock">
<summary>Debug</summary>
<pre id="debug-log"></pre>
</details>
</div>
<script>
const statusEl = document.getElementById('status');
const errorPanelEl = document.getElementById('error-panel');
const errorTitleEl = document.getElementById('error-title');
const errorMessageEl = document.getElementById('error-message');
const debugLogEl = document.getElementById('debug-log');
const workspaceAppEl = document.getElementById('workspace-app');
const chatEl = document.getElementById('chat');
const sidebarBackdropEl = document.getElementById('sidebar-backdrop');
const threadsEl = document.getElementById('threads');
const threadTitleEl = document.getElementById('thread-title');
const messagesEl = document.getElementById('messages');
const modelInputEl = document.getElementById('model-input');
const modelHintEl = document.getElementById('model-hint');
const composerFormEl = document.getElementById('composer-form');
const composerInputEl = document.getElementById('composer-input');
const composerImageInputEl = document.getElementById('composer-image-input');
const composerUploadsEl = document.getElementById('composer-uploads');
const composerHintEl = document.getElementById('composer-hint');
const addImageBtnEl = document.getElementById('add-image-btn');
const sendBtnEl = document.getElementById('send-btn');
const newChatBtnEl = document.getElementById('new-chat-btn');
const magicRenameAllBtnEl = document.getElementById('magic-rename-all-btn');
const magicRenameBtnEl = document.getElementById('magic-rename-btn');
const renameChatBtnEl = document.getElementById('rename-chat-btn');
const deleteChatBtnEl = document.getElementById('delete-chat-btn');
const saveModelBtnEl = document.getElementById('save-model-btn');
const sidebarToggleBtnEl = document.getElementById('sidebar-toggle-btn');
const sidebarToggleIconEl = document.getElementById('sidebar-toggle-icon');
const mobileSidebarToggleBtnEl = document.getElementById('mobile-sidebar-toggle-btn');
const urlParams = new URLSearchParams(window.location.search);
const wantsNativeChatKit = urlParams.get('native') === '1';
const workspaceState = {
config: null,
activeThreadId: null,
currentThread: null,
threads: [],
initialized: false,
sending: false,
magicRenaming: false,
magicRenamingAll: false,
sidebarCollapsed: false,
pendingAssistant: null,
traceHistory: {},
composerUploads: [],
uploadingImages: false,
};
let readyTimeout = null;
let statusTimeout = null;
if (window.marked && typeof window.marked.setOptions === 'function') {
window.marked.setOptions({
breaks: true,
gfm: true,
});
}
const appendLog = (message, data) => {
const rendered = data === undefined ? message : `${message}: ${JSON.stringify(data, null, 2)}`;
debugLogEl.textContent += `${new Date().toISOString()} ${rendered}\n`;
};
const reportClientEvent = async (name, data) => {
try {
await fetch('/client-log', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, data }),
});
} catch (error) {
appendLog('client-log failed', error && error.message ? error.message : String(error));
}
};
const showStatus = (message) => {
if (statusTimeout) {
window.clearTimeout(statusTimeout);
statusTimeout = null;
}
statusEl.textContent = message;
statusEl.classList.remove('hidden');
appendLog('status', message);
};
const hideStatus = () => {
if (statusTimeout) {
window.clearTimeout(statusTimeout);
statusTimeout = null;
}
statusEl.classList.add('hidden');
};
const flashStatus = (message, duration = 2400) => {
showStatus(message);
statusTimeout = window.setTimeout(() => {
statusTimeout = null;
hideStatus();
}, duration);
};
const showPanel = (title, message) => {
errorTitleEl.textContent = title;
errorMessageEl.textContent = message;
errorPanelEl.classList.remove('hidden');
appendLog(title, message);
reportClientEvent(title, { message });
};
const hidePanel = () => {
errorPanelEl.classList.add('hidden');
};
const escapeHtml = (value) => String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const renderPlainText = (value) => escapeHtml(value);
const renderAssistantMarkdown = (value) => {
const source = String(value || '');
if (!source) {
return '';
}
if (!window.marked || typeof window.marked.parse !== 'function') {
return renderPlainText(source).replaceAll('\n', '<br>');
}
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
return renderPlainText(source).replaceAll('\n', '<br>');
}
try {
return window.DOMPurify.sanitize(window.marked.parse(source));
} catch (error) {
appendLog('markdown render failed', error && error.message ? error.message : String(error));
return renderPlainText(source).replaceAll('\n', '<br>');
}
};
const formatTimestamp = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(date);
};
const formatBytes = (value) => {
const size = Number(value || 0);
if (!Number.isFinite(size) || size <= 0) {
return '0 B';
}
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const uploadPreviewUrl = (upload) => upload && upload.preview_url
? String(upload.preview_url)
: `/uploads/${encodeURIComponent(String((upload && upload.id) || ''))}`;
const buildAttachmentPayload = (attachments) => Array.isArray(attachments)
? attachments.map((attachment) => ({ id: attachment.id }))
: [];
const _buildUserParts = (content, attachments) => {
const parts = [];
if (String(content || '').trim()) {
parts.push({ type: 'input_text', text: String(content || '').trim() });
}
for (const attachment of Array.isArray(attachments) ? attachments : []) {
parts.push({
type: 'input_image',
file_id: attachment.id,
name: attachment.name,
mime_type: attachment.mime_type,
detail: 'auto',
preview_url: uploadPreviewUrl(attachment),
});
}
return parts;
};
const messageImageParts = (message) => Array.isArray(message && message.parts)
? message.parts.filter((part) => part && part.type === 'input_image' && (part.preview_url || part.file_id))
: [];
const renderMessageImagesHtml = (message) => {
const imageParts = messageImageParts(message);
if (!imageParts.length) {
return '';
}
return `<div class="message-images">${imageParts.map((part) => {
const src = part.preview_url || `/uploads/${encodeURIComponent(String(part.file_id || ''))}`;
const title = String(part.name || 'Uploaded image');
return `
<figure class="message-image">
<img src="${escapeHtml(src)}" alt="${escapeHtml(title)}" loading="lazy">
<figcaption>${escapeHtml(title)}</figcaption>
</figure>
`;
}).join('')}</div>`;
};
const renderComposerUploads = () => {
const uploads = Array.isArray(workspaceState.composerUploads) ? workspaceState.composerUploads : [];
if (!uploads.length) {
composerUploadsEl.innerHTML = '';
composerUploadsEl.classList.add('hidden');
return;
}
composerUploadsEl.classList.remove('hidden');
composerUploadsEl.innerHTML = `
<div class="composer-upload-list">
${uploads.map((upload) => `
<div class="composer-upload">
<img src="${escapeHtml(uploadPreviewUrl(upload))}" alt="${escapeHtml(upload.name || 'Uploaded image')}" loading="lazy">
<div class="composer-upload-copy">
<span class="composer-upload-title">${escapeHtml(upload.name || 'Uploaded image')}</span>
<span class="composer-upload-meta">${escapeHtml(upload.mime_type || 'image')} · ${escapeHtml(formatBytes(upload.size))}</span>
</div>
<button type="button" class="ghost" data-remove-upload-id="${escapeHtml(upload.id || '')}">Remove</button>
</div>
`).join('')}
</div>
`;
composerUploadsEl.querySelectorAll('[data-remove-upload-id]').forEach((button) => {
button.addEventListener('click', async () => {
await removeComposerUpload(button.getAttribute('data-remove-upload-id'));
});
});
};
const clearComposerUploads = () => {
workspaceState.composerUploads = [];
renderComposerUploads();
};
const removeComposerUpload = async (uploadId) => {
const normalizedUploadId = String(uploadId || '');
if (!normalizedUploadId) {
return;
}
const remaining = workspaceState.composerUploads.filter((upload) => upload.id !== normalizedUploadId);
workspaceState.composerUploads = remaining;
renderComposerUploads();
try {
await apiJson(`/api/uploads/${encodeURIComponent(normalizedUploadId)}`, { method: 'DELETE' });
} catch (error) {
appendLog('upload delete failed', error && error.message ? error.message : String(error));
}
};
const readFileAsDataUrl = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error(`Failed to read ${file.name || 'image'}`));
reader.readAsDataURL(file);
});
const uploadSelectedImages = async (files) => {
const selectedFiles = Array.from(files || []).filter(Boolean);
if (!selectedFiles.length) {
return;
}
workspaceState.uploadingImages = true;
updateActionButtons();
showStatus(`Uploading ${selectedFiles.length} image${selectedFiles.length === 1 ? '' : 's'}...`);
try {
for (const file of selectedFiles) {
if (!String(file.type || '').startsWith('image/')) {
throw new Error(`${file.name || 'This file'} is not an image.`);
}
const dataUrl = await readFileAsDataUrl(file);
const payload = await apiJson('/api/uploads', {
method: 'POST',
body: JSON.stringify({
name: file.name || 'image',
mimeType: file.type || 'image/png',
dataUrl,
}),
});
if (payload && payload.upload) {
workspaceState.composerUploads = workspaceState.composerUploads.concat([payload.upload]);
}
}
renderComposerUploads();
flashStatus(`Image${selectedFiles.length === 1 ? '' : 's'} ready to send.`);
} catch (error) {
hideStatus();
throw error;
} finally {
workspaceState.uploadingImages = false;
composerImageInputEl.value = '';
updateActionButtons();
}
};
const threadInitial = (title) => {
const match = String(title || '').trim().match(/[A-Za-z0-9]/);
return match ? match[0].toUpperCase() : '#';
};
const readSidebarPreference = () => {
try {
const saved = window.localStorage.getItem('codeanywhere.sidebarCollapsed');
if (saved !== null) {
return saved === '1';
}
} catch (error) {
appendLog('sidebar preference unavailable', error && error.message ? error.message : String(error));
}
return window.innerWidth <= 900;
};
const isMobileViewport = () => window.matchMedia('(max-width: 900px)').matches;
const isMagicRenameCandidate = (thread) => {
if (!thread) {
return false;
}
const messageCount = Number(thread.message_count || (thread.messages ? thread.messages.length : 0) || 0);
const titleSource = String(thread.title_source || '').trim().toLowerCase();
return messageCount > 0 && !['manual', 'magic'].includes(titleSource);
};
const countMagicRenameCandidates = () => workspaceState.threads.filter(isMagicRenameCandidate).length;
const updateActionButtons = () => {
const busy = workspaceState.sending || workspaceState.magicRenaming || workspaceState.magicRenamingAll;
const currentThread = workspaceState.currentThread;
const currentMessageCount = Number(
(currentThread && (currentThread.message_count || (currentThread.messages ? currentThread.messages.length : 0))) || 0
);
const candidateCount = countMagicRenameCandidates();
newChatBtnEl.disabled = workspaceState.magicRenaming || workspaceState.magicRenamingAll;
newChatBtnEl.textContent = workspaceState.sidebarCollapsed ? '+' : 'New chat';
newChatBtnEl.title = workspaceState.sidebarCollapsed ? 'New chat' : 'Create a new chat';
magicRenameAllBtnEl.disabled = workspaceState.magicRenaming || workspaceState.magicRenamingAll || candidateCount === 0;
magicRenameAllBtnEl.textContent = workspaceState.sidebarCollapsed
? (candidateCount > 0 ? `AI ${candidateCount}` : 'AI')
: (candidateCount > 0 ? `Magic rename all (${candidateCount})` : 'Magic rename all');
magicRenameAllBtnEl.title = candidateCount > 0
? `Use gpt-5.4-nano to rename ${candidateCount} eligible threads`
: 'Only untouched auto-titled conversations are eligible';
sendBtnEl.disabled = busy || workspaceState.uploadingImages;
addImageBtnEl.disabled = busy || workspaceState.uploadingImages;
magicRenameBtnEl.disabled = busy || !currentThread || currentMessageCount === 0;
renameChatBtnEl.disabled = busy || !currentThread;
deleteChatBtnEl.disabled = busy || !currentThread;
saveModelBtnEl.disabled = busy || !currentThread;
};
const updateSidebarUi = () => {
const mobile = isMobileViewport();
document.body.classList.toggle('sidebar-collapsed', workspaceState.sidebarCollapsed);
document.body.classList.toggle('mobile-sidebar-open', mobile && !workspaceState.sidebarCollapsed);
sidebarToggleIconEl.textContent = workspaceState.sidebarCollapsed ? '>' : '<';
sidebarToggleBtnEl.title = workspaceState.sidebarCollapsed ? 'Expand thread list' : 'Collapse thread list';
mobileSidebarToggleBtnEl.textContent = workspaceState.sidebarCollapsed ? 'Threads' : 'Hide threads';
mobileSidebarToggleBtnEl.setAttribute('aria-expanded', String(!workspaceState.sidebarCollapsed));
sidebarBackdropEl.classList.toggle('hidden', !(mobile && !workspaceState.sidebarCollapsed));
updateActionButtons();
};
const setSidebarCollapsed = (collapsed) => {
workspaceState.sidebarCollapsed = collapsed;
try {
window.localStorage.setItem('codeanywhere.sidebarCollapsed', collapsed ? '1' : '0');
} catch (error) {
appendLog('sidebar preference save failed', error && error.message ? error.message : String(error));
}
updateSidebarUi();
};
const apiJson = async (url, init = {}) => {
const response = await fetch(url, {
credentials: 'same-origin',
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers || {}),
},
});
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = null;
}
}
if (!response.ok) {
const detail = payload && payload.detail ? payload.detail : text || `Request failed (${response.status})`;
throw new Error(detail);
}
return payload || {};
};
const parseSseBlock = (block) => {
const normalized = String(block || '').trim();
if (!normalized) {
return null;
}
let event = 'message';
const dataLines = [];
for (const line of normalized.split('\n')) {
if (!line || line.startsWith(':')) {
continue;
}
if (line.startsWith('event:')) {
event = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
if (!dataLines.length) {
return null;
}
const rawData = dataLines.join('\n');
let data;
try {
data = JSON.parse(rawData);
} catch (error) {
data = { message: rawData };
}
return { event, data };
};
const consumeEventStream = async (url, init = {}, handlers = {}) => {
const response = await fetch(url, {
credentials: 'same-origin',
...init,
headers: {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
...(init.headers || {}),
},
});
if (!response.ok) {
const text = await response.text();
let detail = text || `Request failed (${response.status})`;
if (text) {
try {
const payload = JSON.parse(text);
if (payload && payload.detail) {
detail = payload.detail;
}
} catch (error) {
}
}
throw new Error(detail);
}
if (!response.body) {
throw new Error('Stream response body is unavailable');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const dispatchBlock = (block) => {
const parsed = parseSseBlock(block);
if (!parsed) {
return;
}
if (parsed.event === 'progress' && typeof handlers.onProgress === 'function') {
handlers.onProgress(parsed.data);
return;
}
if (parsed.event === 'final' && typeof handlers.onFinal === 'function') {
handlers.onFinal(parsed.data);
return;
}
if (parsed.event === 'error') {
if (typeof handlers.onError === 'function') {
handlers.onError(parsed.data);
return;
}
throw new Error(parsed.data && parsed.data.message ? parsed.data.message : 'The stream failed.');
}
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
buffer = buffer.replaceAll('\r\n', '\n');
let boundaryIndex = buffer.indexOf('\n\n');
while (boundaryIndex !== -1) {
const block = buffer.slice(0, boundaryIndex);
buffer = buffer.slice(boundaryIndex + 2);
dispatchBlock(block);
boundaryIndex = buffer.indexOf('\n\n');
}
if (done) {
break;
}
}
if (buffer.trim()) {
dispatchBlock(buffer);
}
};
const streamThreadMessage = async (threadId, content, attachments = []) => {
let finalPayload = null;
await consumeEventStream(`/api/threads/${threadId}/messages/stream`, {
method: 'POST',
body: JSON.stringify({
content,
attachments: buildAttachmentPayload(attachments),
}),
}, {
onProgress: (data) => {
applyProgressEvent(threadId, data);
},
onFinal: (data) => {
finalPayload = data;
},
onError: (data) => {
throw new Error(data && data.message ? data.message : 'The stream failed.');
},
});
if (!finalPayload) {
throw new Error('The stream ended before a final reply arrived.');
}
return finalPayload;
};
const cloneTraceItems = (traceItems) => Array.isArray(traceItems)
? traceItems.map((item) => ({ ...item }))
: [];
const clearTraceHistory = (threadId) => {
if (!threadId || !workspaceState.traceHistory[threadId]) {
return;
}
const nextTraceHistory = { ...workspaceState.traceHistory };
delete nextTraceHistory[threadId];
workspaceState.traceHistory = nextTraceHistory;
};
const upsertPendingTraceItem = (pendingAssistant, entry) => {
const traceItems = cloneTraceItems(pendingAssistant.traceItems);
const key = String(entry.key || `${entry.category}:${entry.title}`);
const outputDetail = entry.output_detail ? String(entry.output_detail) : '';
const normalizedEntry = {
key,
category: String(entry.category || 'trace'),
tool_name: String(entry.tool_name || ''),
title: String(entry.title || entry.text || 'Trace event'),
summary: String(entry.summary || entry.text || ''),
detail: String(entry.detail || ''),
output_detail: outputDetail,
};
const index = traceItems.findIndex((item) => item.key === key);
if (index === -1) {
traceItems.push(normalizedEntry);
} else {
// Merge: keep input detail from the call, append output_detail
const merged = { ...traceItems[index], ...normalizedEntry };
if (!merged.detail && traceItems[index].detail) {
merged.detail = traceItems[index].detail;
}
if (outputDetail && !traceItems[index].output_detail) {
merged.output_detail = outputDetail;
}
traceItems[index] = merged;
}
pendingAssistant.traceItems = traceItems;
};
const applyProgressEvent = (threadId, data) => {
const text = data && data.text ? String(data.text) : '';
const summary = data && data.summary ? String(data.summary) : '';
const kind = data && data.kind ? String(data.kind) : (data && data.category ? 'trace' : 'status');
const isTraceOnly = kind === 'trace' && data && data.category;
const existing = workspaceState.pendingAssistant;
const pendingAssistant = existing && existing.threadId === threadId
? {
...existing,
traceItems: cloneTraceItems(existing.traceItems),
}
: {
id: `local-status-${Date.now()}`,
threadId,
role: 'assistant',
content: isTraceOnly ? 'Working...' : (text || summary || 'Working...'),
created_at: new Date().toISOString(),
pending: true,
kind: isTraceOnly ? (existing ? existing.kind || 'status' : 'status') : kind,
traceItems: [],
};
// Only update the visible status text for status/reasoning events,
// not for individual tool call trace events (those go into the collapsed counts).
if (!isTraceOnly) {
if (text.trim()) {
pendingAssistant.content = text;
pendingAssistant.kind = kind;
} else if (!pendingAssistant.content && summary.trim()) {
pendingAssistant.content = summary;
pendingAssistant.kind = kind;
}
}
if (data && data.category) {
upsertPendingTraceItem(pendingAssistant, data);
}
workspaceState.pendingAssistant = pendingAssistant;
if (workspaceState.currentThread && workspaceState.currentThread.id === threadId) {
renderMessages();
}
};
const setPendingAssistant = (threadId, text, kind = 'status') => {
const content = String(text || '');
const existing = workspaceState.pendingAssistant;
if (!content.trim() && !(existing && existing.threadId === threadId)) {
return;
}
if (
existing
&& existing.threadId === threadId
&& existing.content === content
&& existing.kind === kind
) {
return;
}
if (existing && existing.threadId === threadId) {
workspaceState.pendingAssistant = {
...existing,
content,
kind,
traceItems: cloneTraceItems(existing.traceItems),
};
} else {
workspaceState.pendingAssistant = {
id: `local-status-${Date.now()}`,
threadId,
role: 'assistant',
content,
created_at: new Date().toISOString(),
pending: true,
kind,
traceItems: [],
};
}
if (workspaceState.currentThread && workspaceState.currentThread.id === threadId) {
renderMessages();
}
};
const preservePendingAssistantTrace = (threadId) => {
const pendingAssistant = workspaceState.pendingAssistant;
if (
!pendingAssistant
|| pendingAssistant.threadId !== threadId
|| !Array.isArray(pendingAssistant.traceItems)
|| !pendingAssistant.traceItems.length
) {
return;
}
workspaceState.traceHistory = {
...workspaceState.traceHistory,
[threadId]: {
id: `trace-${pendingAssistant.id}`,
role: 'assistant',
traceOnly: true,
content: pendingAssistant.content,
created_at: pendingAssistant.created_at,
traceItems: cloneTraceItems(pendingAssistant.traceItems),
},
};
};
const clearPendingAssistant = (threadId = null, { preserveTrace = false } = {}) => {
if (!workspaceState.pendingAssistant) {
return;
}
if (threadId && workspaceState.pendingAssistant.threadId !== threadId) {
return;
}
if (preserveTrace) {
preservePendingAssistantTrace(workspaceState.pendingAssistant.threadId);
}
workspaceState.pendingAssistant = null;
};
const renderTraceStackHtml = (traceItems) => {
if (!Array.isArray(traceItems) || !traceItems.length) {
return '';
}
// Group items by category for collapsed count display
const groups = {};
for (const entry of traceItems) {
const cat = String(entry.category || 'trace');
if (!groups[cat]) {
groups[cat] = [];
}
groups[cat].push(entry);
}
// Build collapsed count summary line: "🔧 12 read_file · 5 shell_run · 2 handoffs"
const countParts = [];
for (const [cat, items] of Object.entries(groups)) {
if (cat === 'tool_call') {
// Sub-group by tool_name
const toolGroups = {};
for (const item of items) {
const toolName = item.tool_name || item.title || 'tool';
if (!toolGroups[toolName]) {
toolGroups[toolName] = [];
}
toolGroups[toolName].push(item);
}
for (const [toolName, toolItems] of Object.entries(toolGroups)) {
countParts.push({ label: toolName, count: toolItems.length, items: toolItems, cat });
}
} else {
const label = cat === 'subagent' ? 'handoffs' : cat.replaceAll('_', ' ');
countParts.push({ label, count: items.length, items, cat });
}
}
countParts.sort((a, b) => b.count - a.count);
const summaryLine = countParts.map((part) =>
`${part.count} ${escapeHtml(part.label)}`
).join(' · ');
// Render each group's items as expandable detail rows
const detailRows = traceItems.map((entry) => {
const inputSection = entry.detail
? `<div class="trace-detail-section"><span class="trace-detail-label">Input:</span>\n${renderPlainText(entry.detail)}</div>`
: '';
const outputSection = entry.output_detail
? `<div class="trace-detail-section"><span class="trace-detail-label">Output:</span>\n${renderPlainText(entry.output_detail)}</div>`
: '';
const detailContent = inputSection || outputSection
? `${inputSection}${outputSection}`
: renderPlainText('No additional detail captured.');
return `
<details class="trace-bubble ${escapeHtml(entry.category || 'trace')}">
<summary>
<span class="trace-kicker">${escapeHtml(String(entry.category || 'trace').replaceAll('_', ' '))}</span>
<span class="trace-title">${escapeHtml(entry.title || entry.summary || 'Trace event')}</span>
<span class="trace-summary">${escapeHtml(entry.summary || 'Expand for details')}</span>
</summary>
<div class="trace-body">${detailContent}</div>
</details>
`;
}).join('');
return `<div class="trace-stack">
<details class="trace-collapsed-summary">
<summary class="trace-counts-line">🔧 ${summaryLine}</summary>
<div class="trace-expanded-items">${detailRows}</div>
</details>
</div>`;
};
const renderMessageBodyHtml = (message) => {
if (message.traceOnly) {
const summaryHtml = message.content
? `<div class="message-body is-plain">${renderPlainText(message.content)}</div>`
: '';
return `${renderTraceStackHtml(message.traceItems)}${summaryHtml}`;
}
const trimmedContent = String(message.content || '').trim();
let bodyHtml = '';
if (trimmedContent) {
if (message.role === 'assistant' && !message.pending) {
bodyHtml = `<div class="message-body markdown-body">${renderAssistantMarkdown(message.content)}</div>`;
} else {
bodyHtml = `<div class="message-body is-plain">${renderPlainText(message.content)}</div>`;
}
}
const imagesHtml = renderMessageImagesHtml(message);
if (message.pending) {
// Trace counts above status text — not after
return `${renderTraceStackHtml(message.traceItems)}${bodyHtml}${imagesHtml}`;
}
return `${bodyHtml}${imagesHtml}`;
};
const messageLabel = (message) => {
const timestamp = escapeHtml(formatTimestamp(message.created_at) || 'now');
if (message.role === 'user') {
return `You | ${timestamp}`;
}
if (message.traceOnly) {
return `CodeAnywhere | run trace | ${timestamp}`;
}
if (message.pending) {
const phase = message.kind === 'reasoning' ? 'thinking' : 'live status';
return `CodeAnywhere | ${phase} | ${timestamp}`;
}
return `CodeAnywhere | ${timestamp}`;
};
const renderThreads = () => {
if (!workspaceState.threads.length) {
threadsEl.innerHTML = '<div class="empty-state">Threads show up here after your first prompt. The sidebar stays compact, not full-screen.</div>';
updateActionButtons();
return;
}
threadsEl.innerHTML = workspaceState.threads.map((thread) => {
const activeClass = thread.id === workspaceState.activeThreadId ? 'active' : '';
const updated = formatTimestamp(thread.updated_at) || 'just now';
return `
<button type="button" class="thread-card ${activeClass}" data-thread-id="${thread.id}" title="${escapeHtml(thread.title)}">
<span class="thread-badge">${escapeHtml(threadInitial(thread.title))}</span>
<span class="thread-copy">
<strong class="thread-title-text">${escapeHtml(thread.title)}</strong>
<span class="meta">${escapeHtml(thread.model)}</span>
<span class="meta">Updated ${escapeHtml(updated)}</span>
</span>
</button>
`;
}).join('');
threadsEl.querySelectorAll('[data-thread-id]').forEach((button) => {
button.addEventListener('click', async () => {
await loadThread(button.dataset.threadId);
});
});
updateActionButtons();
};
const renderMessages = () => {
const thread = workspaceState.currentThread;
if (!thread) {
threadTitleEl.textContent = 'Start a chat';
messagesEl.innerHTML = '<div class="empty-state">Type your first prompt. Its first line becomes the thread title, and the sidebar will update automatically.</div>';
composerHintEl.textContent = 'Your first prompt creates the thread. Shift+Enter adds a newline, and images can be attached.';
modelHintEl.textContent = 'The first prompt uses the model shown here.';
modelInputEl.disabled = false;
if (!modelInputEl.value.trim()) {
modelInputEl.value = workspaceState.config ? workspaceState.config.defaultModel : 'gpt-5.4-mini';
}
updateActionButtons();
return;
}
threadTitleEl.textContent = thread.title;
composerHintEl.textContent = `Current model: ${thread.model}. Attach images or type a prompt.`;
modelHintEl.textContent = 'Save a model override for this thread, or use /model <name> in chat.';
modelInputEl.disabled = false;
modelInputEl.value = thread.model;
const messages = Array.isArray(thread.messages) ? thread.messages.slice() : [];
if (workspaceState.traceHistory[thread.id]) {
messages.push(workspaceState.traceHistory[thread.id]);
}
if (workspaceState.pendingAssistant && workspaceState.pendingAssistant.threadId === thread.id) {
messages.push(workspaceState.pendingAssistant);
}
if (!messages.length) {
messagesEl.innerHTML = '<div class="empty-state">This thread is empty. The first non-command line you send becomes its title.</div>';
updateActionButtons();
return;
}
messagesEl.innerHTML = messages.map((message) => {
const pendingClass = message.pending ? ' pending' : '';
const traceOnlyClass = message.traceOnly ? ' trace-only' : '';
return `
<article class="message ${message.role}${pendingClass}${traceOnlyClass}">
<div class="message-label">${messageLabel(message)}</div>
${renderMessageBodyHtml(message)}
</article>
`;
}).join('');
messagesEl.scrollTop = messagesEl.scrollHeight;
updateActionButtons();
};
const refreshThreads = async (preferredThreadId) => {
const data = await apiJson('/api/threads', { method: 'GET' });
workspaceState.threads = data.threads || [];
if (preferredThreadId) {
workspaceState.activeThreadId = preferredThreadId;
} else if (!workspaceState.activeThreadId || !workspaceState.threads.some((thread) => thread.id === workspaceState.activeThreadId)) {
workspaceState.activeThreadId = workspaceState.threads[0] ? workspaceState.threads[0].id : null;
}
renderThreads();
if (!workspaceState.activeThreadId) {
workspaceState.currentThread = null;
renderMessages();
return;
}
const threadData = await apiJson(`/api/threads/${workspaceState.activeThreadId}`, { method: 'GET' });
workspaceState.currentThread = threadData.thread;
renderMessages();
};
const createThread = async () => {
showStatus('Creating a new chat...');
const selectedModel = modelInputEl.value.trim() || (workspaceState.config ? workspaceState.config.defaultModel : 'gpt-5.4-mini');
const data = await apiJson('/api/threads', {
method: 'POST',
body: JSON.stringify({ model: selectedModel }),
});
workspaceState.activeThreadId = data.thread.id;
await refreshThreads(data.thread.id);
if (isMobileViewport()) {
setSidebarCollapsed(true);
}
hideStatus();
};
const loadThread = async (threadId) => {
workspaceState.activeThreadId = threadId;
const data = await apiJson(`/api/threads/${threadId}`, { method: 'GET' });
workspaceState.currentThread = data.thread;
renderThreads();
renderMessages();
if (isMobileViewport()) {
setSidebarCollapsed(true);
}
};
const renameThread = async () => {
if (!workspaceState.currentThread) {
return;
}
const nextTitle = window.prompt('Rename thread', workspaceState.currentThread.title);
if (!nextTitle) {
return;
}
showStatus('Renaming thread...');
await apiJson(`/api/threads/${workspaceState.currentThread.id}`, {
method: 'PATCH',
body: JSON.stringify({ title: nextTitle }),
});
await refreshThreads(workspaceState.currentThread.id);
hideStatus();
};
const magicRenameThread = async () => {
if (!workspaceState.currentThread) {
return;
}
const threadId = workspaceState.currentThread.id;
workspaceState.magicRenaming = true;
updateActionButtons();
showStatus('Summarizing this thread with gpt-5.4-nano...');
try {
const data = await apiJson(`/api/threads/${threadId}/magic-rename`, {
method: 'POST',
});
workspaceState.currentThread = data.thread;
await refreshThreads(threadId);
flashStatus('Thread renamed with gpt-5.4-nano.');
} catch (error) {
hideStatus();
showPanel('Magic rename failed', error && error.message ? error.message : String(error));
} finally {
workspaceState.magicRenaming = false;
updateActionButtons();
}
};
const magicRenameAllThreads = async () => {
const candidateCount = countMagicRenameCandidates();
if (!candidateCount) {
flashStatus('No eligible threads to rename.');
return;
}
workspaceState.magicRenamingAll = true;
updateActionButtons();
showStatus(`Summarizing ${candidateCount} threads with gpt-5.4-nano...`);
try {
const data = await apiJson('/api/threads/magic-rename-all', {
method: 'POST',
});
await refreshThreads(workspaceState.activeThreadId);
const renamedCount = Number(data.renamedCount || 0);
const skippedCount = Number(data.skippedCount || 0);
if (renamedCount > 0) {
flashStatus(
skippedCount > 0
? `Renamed ${renamedCount} threads. Skipped ${skippedCount}.`
: `Renamed ${renamedCount} threads.`
);
} else {
flashStatus('No eligible threads were renamed.');
}
} catch (error) {
hideStatus();
showPanel('Magic rename all failed', error && error.message ? error.message : String(error));
} finally {
workspaceState.magicRenamingAll = false;
updateActionButtons();
}
};
const deleteThread = async () => {
if (!workspaceState.currentThread) {
return;
}
const confirmed = window.confirm(`Delete thread "${workspaceState.currentThread.title}"?`);
if (!confirmed) {
return;
}
showStatus('Deleting thread...');
const deletedThreadId = workspaceState.currentThread.id;
await apiJson(`/api/threads/${deletedThreadId}`, { method: 'DELETE' });
workspaceState.activeThreadId = workspaceState.threads.find((thread) => thread.id !== deletedThreadId)?.id || null;
await refreshThreads(workspaceState.activeThreadId);
hideStatus();
};
const saveModel = async () => {
if (!workspaceState.currentThread) {
return;
}
const model = modelInputEl.value.trim();
if (!model) {
showPanel('Model is required', 'Enter a model name before saving it for this thread.');
return;
}
showStatus('Saving model...');
await apiJson(`/api/threads/${workspaceState.currentThread.id}`, {
method: 'PATCH',
body: JSON.stringify({ model }),
});
await refreshThreads(workspaceState.currentThread.id);
hideStatus();
};
const sendMessage = async () => {
const content = composerInputEl.value.trim();
const attachments = Array.isArray(workspaceState.composerUploads) ? workspaceState.composerUploads.slice() : [];
if ((!content && !attachments.length) || workspaceState.sending || workspaceState.uploadingImages) {
return;
}
if (!workspaceState.currentThread) {
await createThread();
}
workspaceState.sending = true;
sendBtnEl.textContent = 'Sending...';
sendBtnEl.disabled = true;
hidePanel();
showStatus('Running the agent...');
const threadId = workspaceState.currentThread.id;
clearTraceHistory(threadId);
const optimisticMessages = (workspaceState.currentThread.messages || []).concat([
{
id: `local-${Date.now()}`,
role: 'user',
content,
parts: attachments.length ? _buildUserParts(content, attachments) : [],
created_at: new Date().toISOString(),
},
]);
workspaceState.currentThread = {
...workspaceState.currentThread,
messages: optimisticMessages,
};
setPendingAssistant(threadId, 'Working...', 'status');
try {
const data = await streamThreadMessage(threadId, content, attachments);
clearPendingAssistant(threadId, { preserveTrace: true });
workspaceState.currentThread = data.thread;
composerInputEl.value = '';
clearComposerUploads();
renderMessages();
await refreshThreads(threadId);
hideStatus();
} catch (error) {
clearPendingAssistant(threadId, { preserveTrace: true });
renderMessages();
showPanel('Message failed', error && error.message ? error.message : String(error));
await refreshThreads(threadId);
hideStatus();
} finally {
workspaceState.sending = false;
sendBtnEl.textContent = 'Send';
renderMessages();
}
};
const bindWorkspaceUi = () => {
if (workspaceState.initialized) {
return;
}
workspaceState.initialized = true;
sidebarToggleBtnEl.addEventListener('click', () => {
setSidebarCollapsed(!workspaceState.sidebarCollapsed);
});
mobileSidebarToggleBtnEl.addEventListener('click', () => {
setSidebarCollapsed(!workspaceState.sidebarCollapsed);
});
sidebarBackdropEl.addEventListener('click', () => {
setSidebarCollapsed(true);
});
window.addEventListener('resize', () => {
updateSidebarUi();
});
newChatBtnEl.addEventListener('click', async () => {
try {
await createThread();
} catch (error) {
showPanel('New chat failed', error && error.message ? error.message : String(error));
}
});
magicRenameAllBtnEl.addEventListener('click', async () => {
try {
await magicRenameAllThreads();
} catch (error) {
showPanel('Magic rename all failed', error && error.message ? error.message : String(error));
}
});
magicRenameBtnEl.addEventListener('click', async () => {
try {
await magicRenameThread();
} catch (error) {
showPanel('Magic rename failed', error && error.message ? error.message : String(error));
}
});
renameChatBtnEl.addEventListener('click', async () => {
try {
await renameThread();
} catch (error) {
showPanel('Rename failed', error && error.message ? error.message : String(error));
}
});
deleteChatBtnEl.addEventListener('click', async () => {
try {
await deleteThread();
} catch (error) {
showPanel('Delete failed', error && error.message ? error.message : String(error));
}
});
saveModelBtnEl.addEventListener('click', async () => {
try {
await saveModel();
} catch (error) {
showPanel('Model save failed', error && error.message ? error.message : String(error));
}
});
addImageBtnEl.addEventListener('click', () => {
composerImageInputEl.click();
});
composerImageInputEl.addEventListener('change', async (event) => {
try {
await uploadSelectedImages(event.target.files);
} catch (error) {
showPanel('Image upload failed', error && error.message ? error.message : String(error));
}
});
composerFormEl.addEventListener('submit', async (event) => {
event.preventDefault();
await sendMessage();
});
composerInputEl.addEventListener('keydown', async (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
await sendMessage();
}
});
};
const startWorkspaceUi = async (config, reason = null) => {
workspaceState.config = config;
workspaceAppEl.classList.remove('hidden');
chatEl.classList.add('hidden');
bindWorkspaceUi();
setSidebarCollapsed(readSidebarPreference());
renderComposerUploads();
if (reason) {
showPanel('Native ChatKit switched back to workspace mode', reason);
reportClientEvent('workspace-mode', { reason });
} else {
hidePanel();
}
await refreshThreads(workspaceState.activeThreadId);
hideStatus();
appendLog('ui-mode', 'workspace');
};
const initChatKit = async (config) => {
workspaceAppEl.classList.add('hidden');
chatEl.classList.remove('hidden');
showStatus('Loading native ChatKit...');
chatEl.addEventListener('chatkit.ready', () => {
if (readyTimeout) {
window.clearTimeout(readyTimeout);
readyTimeout = null;
}
hidePanel();
hideStatus();
appendLog('chatkit.ready');
reportClientEvent('chatkit.ready', {});
}, { once: true });
chatEl.addEventListener('chatkit.error', async (event) => {
const err = event.detail && event.detail.error ? event.detail.error : null;
const message = err && err.message ? err.message : String(err || 'Unknown ChatKit error');
if (readyTimeout) {
window.clearTimeout(readyTimeout);
readyTimeout = null;
}
await startWorkspaceUi(config, message);
});
chatEl.addEventListener('chatkit.log', (event) => {
appendLog(`chatkit.log:${event.detail?.name || 'unknown'}`, event.detail?.data || {});
});
readyTimeout = window.setTimeout(async () => {
await startWorkspaceUi(
config,
'The ChatKit widget did not become ready in time, so the workspace UI stayed active.'
);
}, 12000);
await customElements.whenDefined('openai-chatkit');
chatEl.setOptions({
api: {
url: config.apiUrl,
domainKey: config.domainKey,
fetch: (url, init) => fetch(url, { ...init, credentials: 'same-origin' }),
},
theme: {
colorScheme: 'dark',
radius: 'round',
},
frameTitle: 'CodeAnywhere Chat',
header: {
enabled: true,
title: {
enabled: true,
text: 'CodeAnywhere',
},
},
history: {
enabled: true,
showDelete: true,
showRename: true,
},
startScreen: {
greeting: 'CodeAnywhere',
prompts: [
{ label: 'Directory structure', prompt: 'Show me the directory structure of /repos/' },
{ label: 'Pending changes', prompt: 'What repos have pending changes?' },
{ label: 'Run karakeep tests', prompt: 'Run the tests in karakeep' },
],
},
composer: {
placeholder: `Ask anything, or /model <name> to switch models (default: ${config.defaultModel})`,
attachments: {
enabled: true,
},
},
threadItemActions: {
retry: true,
feedback: false,
},
});
appendLog('ui-mode', 'native-chatkit');
};
window.addEventListener('error', (event) => {
showPanel('Browser Error', event.message || 'Unknown browser error');
});
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
const message = reason && reason.message ? reason.message : String(reason || 'Unknown promise rejection');
showPanel('Unhandled Promise Rejection', message);
});
const loadConfig = async () => {
const response = await fetch('/ui-config', { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`Failed to load UI config (${response.status})`);
}
return response.json();
};
const init = async () => {
showStatus('Loading CodeAnywhere...');
const config = await loadConfig();
appendLog('ui-config', {
usingFallbackDomainKey: config.usingFallbackDomainKey,
defaultModel: config.defaultModel,
});
if (wantsNativeChatKit && !config.usingFallbackDomainKey) {
await initChatKit(config);
return;
}
if (wantsNativeChatKit && config.usingFallbackDomainKey) {
await startWorkspaceUi(config, 'Native ChatKit is unavailable without a valid production domain key.');
return;
}
await startWorkspaceUi(config);
};
init().catch((error) => {
showPanel('Initialization Failed', error && error.message ? error.message : String(error));
});
</script>
</body>
</html>