Adds Claude-style split-view code panels to duck.ai (and maybe other future enhancements)
// ==UserScript==
// @name Duck.ai Chat Pro
// @namespace http://tampermonkey.net/
// @version 2.2
// @description Adds Claude-style split-view code panels to duck.ai (and maybe other future enhancements)
// @author Christopher Waldau
// @license GNU GPLv3
// @match https://duck.ai/*
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-clike.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-java.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-c.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-cpp.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-csharp.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-ruby.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-rust.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-tsx.min.js
// @icon https://duck.ai/favicon.ico
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
injectStyles(); // duck.ai custom styles (inline)
injectPrismCSS(); // fetch and inject Prism CSS safely
setupCodePanel();
setupToggleButton();
}
// Sidebar management
let sidebarWasCollapsed = false;
// Code conversion toggle state (default: enabled)
let codeConversionEnabled = true;
// Load saved preference from localStorage
try {
const saved = localStorage.getItem('duck-ai-code-conversion-enabled');
if (saved !== null) {
codeConversionEnabled = saved === 'true';
}
} catch (e) {
console.warn('Could not load code conversion preference:', e);
}
// Save preference to localStorage
function saveCodeConversionPreference(enabled) {
try {
localStorage.setItem('duck-ai-code-conversion-enabled', enabled.toString());
} catch (e) {
console.warn('Could not save code conversion preference:', e);
}
}
function getSidebar() {
// Find sidebar by stable structural selectors, not obfuscated classes
return document.querySelector('section[class]') ||
document.querySelector('aside') ||
document.querySelector('nav');
}
function getSidebarToggle() {
// Find the sidebar toggle button
return document.querySelector('button[aria-label*="sidebar"]');
}
function isSidebarCollapsed() {
try {
return localStorage.getItem('duckaiSidebarCollapsed') === 'true';
} catch (e) {
const sidebar = getSidebar();
if (!sidebar) return false;
return sidebar.offsetWidth < 100 || sidebar.style.display === 'none';
}
}
function toggleSidebar() {
const toggle = getSidebarToggle();
if (toggle) {
toggle.click();
}
}
function collapseSidebarIfNeeded() {
if (!isSidebarCollapsed()) {
sidebarWasCollapsed = false;
toggleSidebar();
} else {
sidebarWasCollapsed = true;
}
}
function restoreSidebarIfNeeded() {
if (!sidebarWasCollapsed && isSidebarCollapsed()) {
toggleSidebar();
}
}
function setupToggleButton() {
let attempts = 0;
const check = setInterval(() => {
attempts++;
const sidebar = getSidebar();
if (sidebar && sidebar.querySelector('button')) {
clearInterval(check);
insertToggleButton(sidebar);
watchSidebarState();
} else if (attempts > 50) {
clearInterval(check);
}
}, 100);
}
function watchSidebarState() {
updateToggleVisibility();
window.addEventListener('storage', (e) => {
if (e.key === 'duckaiSidebarCollapsed') {
updateToggleVisibility();
}
});
const originalSetItem = localStorage.setItem.bind(localStorage);
localStorage.setItem = function(key, value) {
originalSetItem(key, value);
if (key === 'duckaiSidebarCollapsed') {
setTimeout(updateToggleVisibility, 50);
}
};
const sidebar = getSidebar();
if (sidebar) {
const observer = new MutationObserver(() => {
updateToggleVisibility();
});
observer.observe(sidebar, {
attributes: true,
attributeFilter: ['style', 'class']
});
}
}
function updateToggleVisibility() {
const toggleContainer = document.querySelector('.code-conversion-toggle-container');
if (!toggleContainer) return;
const sidebarCollapsed = isSidebarCollapsed();
if (sidebarCollapsed) {
toggleContainer.style.display = 'none';
} else {
toggleContainer.style.display = '';
}
}
function observeSidebarChanges() {
// Watch for sidebar being replaced/modified
const app = document.getElementById('app');
if (!app) return;
let lastSidebarCollapsed = isSidebarCollapsed();
const observer = new MutationObserver(() => {
const currentlyCollapsed = isSidebarCollapsed();
// Sidebar was collapsed and is now expanded
if (lastSidebarCollapsed && !currentlyCollapsed) {
const sidebar = getSidebar();
// Check if toggle is missing
if (sidebar && !document.getElementById('code-conversion-toggle')) {
insertToggleButton(sidebar);
}
}
lastSidebarCollapsed = currentlyCollapsed;
});
observer.observe(app, {
childList: true,
subtree: true
});
}
function insertToggleButton(sidebar) {
const toggleContainer = document.createElement('div');
toggleContainer.className = 'code-conversion-toggle-container';
toggleContainer.innerHTML = `
<button class="code-conversion-toggle" id="code-conversion-toggle" title="Toggle code block conversion">
<div class="toggle-content">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
<span class="toggle-label">Code Panels</span>
</div>
<div class="toggle-switch">
<div class="toggle-slider"></div>
</div>
</button>
`;
// Find insertion point using stable text content - never rely on obfuscated classes
// Strategy 1: insert after the "Chat Protection" button
const allButtons = Array.from(sidebar.querySelectorAll('button'));
const chatProtectionBtn = allButtons.find(btn =>
btn.textContent.trim().includes('Chat Protection')
);
if (chatProtectionBtn) {
chatProtectionBtn.insertAdjacentElement('afterend', toggleContainer);
} else {
// Strategy 2: insert before the DuckDuckGo footer text
const allEls = Array.from(sidebar.querySelectorAll('span, div'));
const ddgText = allEls.find(el =>
el.children.length === 0 && el.textContent.trim() === 'DuckDuckGo'
);
if (ddgText) {
// Walk up to find the ancestor that is a direct child of sidebar
let ancestor = ddgText.parentElement;
while (ancestor && ancestor.parentElement !== sidebar) {
ancestor = ancestor.parentElement;
}
if (ancestor) {
sidebar.insertBefore(toggleContainer, ancestor);
} else {
sidebar.appendChild(toggleContainer);
}
} else {
// Strategy 3: just append
sidebar.appendChild(toggleContainer);
}
}
// Set initial state
const toggleBtn = document.getElementById('code-conversion-toggle');
if (!toggleBtn) return;
if (codeConversionEnabled) toggleBtn.classList.add('enabled');
// Click handler
toggleBtn.addEventListener('click', () => {
codeConversionEnabled = !codeConversionEnabled;
saveCodeConversionPreference(codeConversionEnabled);
if (codeConversionEnabled) {
toggleBtn.classList.add('enabled');
const chatWrapper = document.querySelector('.chat-wrapper');
if (chatWrapper && window.codeManager) {
window.codeManager.convertCodeBlocks(chatWrapper);
}
} else {
toggleBtn.classList.remove('enabled');
if (window.codeManager) {
window.codeManager.closePanel();
window.codeManager.revertAllFilePanels();
}
}
});
}
/**
* TIMESTAMPS
*/
const TS_TAG = '[Duck.ai timestamps]';
let tsScheduled = false;
let tsCache = {
chatId: null,
assistantMessages: null
};
function runWhenIdle(fn) {
if ('requestIdleCallback' in window) {
requestIdleCallback(fn, { timeout: 2000 });
} else {
setTimeout(fn, 50);
}
}
function scheduleTimestamps(root) {
if (tsScheduled) return;
tsScheduled = true;
runWhenIdle(() => {
tsScheduled = false;
addAssistantTimestampsIn(root);
});
}
// ── IndexedDB helpers ────────────────────────────────────────────────────
// IndexedDB location: savedAiChatData (default) > saved-chats > <chatId>
const IDB_NAME = 'savedAIChatData';
const IDB_STORE = 'saved-chats';
/**
* Open the duck.ai IndexedDB database at its current version.
* We first probe for the existing version via indexedDB.databases() (where
* available) or a version-1 open, then re-open at that exact version so we
* never trigger onupgradeneeded and never create a shadow database.
* Returns a Promise<IDBDatabase>.
*/
async function openDuckAIDatabase() {
// Preferred path: modern browsers expose indexedDB.databases()
if (typeof indexedDB.databases === 'function') {
const list = await indexedDB.databases();
const entry = list.find(d => d.name === IDB_NAME);
if (!entry) {
throw new Error(`${TS_TAG} IndexedDB "${IDB_NAME}" not found`);
}
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_NAME, entry.version);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => req.transaction.abort(); // should never fire
});
}
// Fallback: open at version 1 just to read the real version, then reopen
const version = await new Promise((resolve, reject) => {
const probe = indexedDB.open(IDB_NAME, 1);
probe.onsuccess = () => {
const v = probe.result.version;
probe.result.close();
resolve(v);
};
probe.onerror = () => reject(probe.error);
probe.onupgradeneeded = (e) => {
// DB didn't exist at v1 — abort so we don't create it
e.target.transaction.abort();
reject(new Error(`${TS_TAG} "${IDB_NAME}" does not exist`));
};
});
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_NAME, version);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => req.transaction.abort();
});
}
/**
* Read a single chat record by chatId from IndexedDB.
* Returns a Promise<object|null> – the parsed chat object or null if not found.
*
* Duck.ai stores each chat as a JSON-stringified value whose top-level keys
* are: title, model, messages.
*/
async function loadChatFromIndexedDB(chatId) {
let db;
try {
db = await openDuckAIDatabase();
} catch (e) {
console.warn(TS_TAG, 'could not open IndexedDB', e);
return null;
}
try {
const result = await new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readonly');
const req = tx.objectStore(IDB_STORE).get(chatId);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (result === undefined || result === null) return null;
// Value may be a raw object or a JSON string
if (typeof result === 'string') {
try { return JSON.parse(result); } catch { return null; }
}
return result;
} catch (e) {
console.warn(TS_TAG, 'error reading from IndexedDB store', e);
return null;
} finally {
db.close();
}
}
// ── Cache & lookup ────────────────────────────────────────────────────────
/**
* Return cached assistant messages for chatId, or fetch from IndexedDB.
* requiredCount is used only to decide whether the existing cache is sufficient.
* Returns a Promise<{chatId, assistantMessages}|null>.
*/
async function getChatContext(chatId, requiredCount) {
if (!chatId) return null;
// Reuse cache if it covers all the messages we need
if (
tsCache.chatId === chatId &&
tsCache.assistantMessages &&
tsCache.assistantMessages.length >= requiredCount
) {
return tsCache;
}
const chat = await loadChatFromIndexedDB(chatId);
if (!chat || !Array.isArray(chat.messages)) return null;
const assistantMessages = chat.messages.filter(m => m.role === 'assistant');
tsCache = { chatId, assistantMessages };
return tsCache;
}
// Extract chatId from an assistant message id:
// "72eb1718-...-assistant-message-0-1" → "72eb1718-..."
function getChatIdFromAssistantNode(node) {
if (!node || !node.id) return null;
const match = node.id.match(/^([0-9a-fA-F-]+)-assistant-message/);
return match ? match[1] : null;
}
function formatTimestamp(iso) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: '2-digit',
hour12: true, // set false for 24h
second: undefined
});
}
// Add timestamps to assistant messages within a given root (e.g. chat-wrapper or a new node)
async function addAssistantTimestampsIn(root) {
if (!root) return;
// Find any assistant node to get chatId
const firstAssistant = root.querySelector('div[id*="-assistant-message-"]:not([id^="heading-"])');
if (!firstAssistant) return;
const chatId = getChatIdFromAssistantNode(firstAssistant);
if (!chatId) return;
// All *outer* assistant nodes currently in DOM
const assistantNodes = Array.from(
root.querySelectorAll('div[id*="-assistant-message-"]:not([id^="heading-"])')
);
if (!assistantNodes.length) return;
// Fetch chat data from IndexedDB (async)
const ctx = await getChatContext(chatId, assistantNodes.length);
if (!ctx) return;
const { assistantMessages } = ctx;
// We also need user messages for the user timestamp injection
const chat = await loadChatFromIndexedDB(chatId);
const userMessages = chat && Array.isArray(chat.messages)
? chat.messages.filter(m => m.role === 'user')
: [];
assistantNodes.forEach((node, idx) => {
// ── Assistant timestamp ──────────────────────────────────────────
const assistantMsg = assistantMessages[idx];
if (assistantMsg && assistantMsg.createdAt && !node.querySelector('.duckai-timestamp')) {
const span = makeTimestampSpan(assistantMsg.createdAt);
const directDivChildren = Array.from(node.children).filter(el => el.tagName === 'DIV');
const footer = directDivChildren[directDivChildren.length - 1] || node;
if (footer.firstChild) {
footer.insertBefore(span, footer.firstChild);
} else {
footer.appendChild(span);
}
}
// ── User timestamp ───────────────────────────────────────────────
// The user message is the sibling immediately above the assistant node
const userNode = node.previousElementSibling;
const userMsg = userMessages[idx];
if (userNode && userMsg && userMsg.createdAt && !userNode.querySelector('.duckai-timestamp')) {
const span = makeTimestampSpan(userMsg.createdAt);
// User bubble has 2 nested divs; inject into the 2nd one
const directDivChildren = Array.from(userNode.children).filter(el => el.tagName === 'DIV');
const target = directDivChildren[1] || directDivChildren[0] || userNode;
if (target.firstChild) {
target.insertBefore(span, target.firstChild);
} else {
target.appendChild(span);
}
}
});
}
function makeTimestampSpan(iso) {
const span = document.createElement('span');
span.className = 'duckai-timestamp';
span.textContent = formatTimestamp(iso);
span.style.opacity = '0.6';
span.style.fontSize = '0.75rem';
span.style.marginLeft = '0.5rem';
span.style.display = 'inline-flex';
span.style.alignItems = 'center';
span.style.whiteSpace = 'nowrap';
return span;
}
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
/* ==================== CLAUDE-STYLE CODE OUTPUT STYLES ==================== */
/* Reverted code block styles - matches Duck.ai's native appearance */
.duck-code-block {
box-sizing: border-box;
background-color: var(--sds-color-background-container-pre) !important;
border: 1px solid var(--sds-color-palette-shade-06);
border-radius: var(--sds-radius-x03);
margin: 8px 0;
}
.duck-code-header {
display: flex;
justify-content: space-between;
padding: var(--sds-space-x03) var(--sds-space-x03) var(--sds-space-x02) var(--sds-space-x04) !important;
}
.duck-code-lang-container {
display: flex;
align-items: center;
gap: 8px;
}
.duck-code-lang-container * {
color: var(--sds-color-text-on-dark-02);
font-size: var(--sds-font-size-label) !important;
}
.duck-code-lang-container i {
width: 16px;
height: 16px;
}
.duck-code-lang-label {
font-family: var(--sds-font-family-monospace) !important;
line-height: 2 !important;
margin: 0;
}
.duck-code-button-container {
position: relative;
}
.duck-code-copy-btn,
.duck-code-copied-btn {
color: var(--sds-color-text-accent-01);
background: transparent;
border: none;
cursor: pointer;
animation: none;
text-transform: capitalize;
transition: opacity 0.25s ease-in-out;
opacity: 1;
font-weight: var(--sds-font-weight-normal);
padding: 4px 8px !important;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--sds-font-size-label);
}
.duck-code-copy-btn:hover,
.duck-code-copied-btn:hover {
background-color: var(--theme-col-bg-button-ghost-hover);
border-radius: var(--rounded-md);
}
.duck-code-copy-btn svg,
.duck-code-copied-btn svg {
width: 16px;
height: 16px;
}
.duck-code-copied-btn {
cursor: default;
position: absolute;
right: 0;
width: max-content;
opacity: 0;
visibility: hidden;
}
.duck-code-content {
color: var(--sds-color-text-01);
font-family: var(--sds-font-family-monospace);
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
font-size: var(--sds-font-size-label);
line-height: var(--sds-font-line-height-body);
tab-size: 4;
hyphens: none;
padding: var(--sds-space-x02, 8px) var(--sds-space-x04, 16px) var(--sds-space-x04, 16px) !important;
margin: 0;
overflow: auto;
border: none;
background: transparent;
}
.duck-code-content code {
color: var(--sds-color-text-01);
font-family: var(--sds-font-family-monospace) !important;
font-size: var(--sds-font-size-label) !important;
line-height: var(--sds-font-line-height-body);
background-color: transparent;
white-space: pre-wrap !important;
word-wrap: break-word;
display: block;
background: 0 0 !important;
}
.duck-code-content span {
background-color: transparent;
}
/* Toggle button in sidebar */
.code-conversion-toggle-container {
padding: 0;
margin: 0;
overflow: hidden;
width: 100%;
flex-shrink: 0;
}
.code-conversion-toggle-container button {
border-radius: var(--sds-radius-x03);
padding: 8px var(--sds-space-x03);
}
.code-conversion-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.2s;
font-size: 14px;
color: inherit;
white-space: nowrap;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
}
.code-conversion-toggle:hover {
background: rgba(0, 0, 0, 0.05);
}
.toggle-content {
display: flex;
align-items: center;
gap: 12px;
}
.code-conversion-toggle svg {
flex-shrink: 0;
opacity: 0.7;
}
.toggle-label {
font-weight: 400;
text-align: left;
font-size: 14px;
}
.toggle-switch {
width: 36px;
height: 20px;
background: #ccc;
border-radius: 10px;
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.code-conversion-toggle.enabled .toggle-switch {
background: #2196f3;
}
.toggle-slider {
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.code-conversion-toggle.enabled .toggle-slider {
transform: translateX(16px);
}
/* Keep the app container as is, but add wrapper for split view */
.chat-code-wrapper {
display: flex !important;
height: 100vh;
overflow: hidden;
flex: 1;
}
/* Left panel - Chat area (the main content, not sidebar) */
.chat-code-wrapper > .chat-wrapper {
flex: 1;
min-width: 400px;
display: flex;
flex-direction: column;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
}
/* When code panel is closed, chat takes full width */
.chat-code-wrapper:not(.code-panel-open) > .chat-wrapper {
max-width: 100%;
flex: 1;
}
/* When code panel is open, chat takes 60% */
.chat-code-wrapper.code-panel-open > .chat-wrapper {
flex: 0 0 60%;
max-width: 60%;
}
/* Override Duck.ai's centered chat layout to fit in narrower panel */
.chat-wrapper [class*="ChatMessages"],
.chat-wrapper [class*="chat-messages"],
.chat-wrapper main {
max-width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* Make message containers full width */
.chat-wrapper [class*="Message"],
.chat-wrapper [class*="message"] {
max-width: 100% !important;
}
/* Right panel - Code preview */
.code-panel {
flex: 0 0 0;
width: 0;
display: flex;
flex-direction: column;
background: #f8f8f8;
position: relative;
overflow: hidden;
}
/* When code panel is open, it takes 40% */
.chat-code-wrapper.code-panel-open .code-panel {
flex: 0 0 40%;
width: 40%;
}
/* Code panel header with copy button */
.code-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
background: #fff;
position: sticky;
top: 0;
z-index: 10;
}
.code-panel-filename {
font-weight: 600;
font-size: 14px;
color: #333;
flex: 1;
}
.code-panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.code-close-button {
padding: 4px;
border: none;
background: transparent;
cursor: pointer;
font-size: 18px;
color: #666;
transition: all 0.2s !important;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
}
.code-close-button:hover {
background: #f0f0f0;
color: #333;
}
.code-copy-button {
padding: 6px 12px;
border: 1px solid #d0d0d0;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.code-copy-button:hover {
background: #f5f5f5;
border-color: #b0b0b0;
}
.code-copy-button.copied {
background: #e8f5e9;
border-color: #4caf50;
color: #2e7d32;
}
/* Code content area */
.code-panel-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.code-panel-content pre {
margin: 0;
padding: 0;
background: #fff;
border-radius: 8px;
border: 1px solid #e0e0e0;
overflow-x: auto;
font-family: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 13px;
line-height: 1.6;
display: flex;
}
.code-line-numbers {
padding: 8px 0 8px 0;
text-align: right;
user-select: none;
color: #999;
border-right: 1px solid #e0e0e0;
background: #fafafa;
min-width: 50px;
}
.code-line-numbers div {
line-height: 1.6;
}
.code-content-wrapper {
flex: 1;
padding: 8px;
overflow-x: auto;
white-space: pre;
}
#code-content pre {
white-space: pre;
}
#code-content code {
white-space: pre-wrap;
display: block;
line-height: 1.6;
}
.code-panel-content code {
font-family: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
background: transparent !important;
}
/* File panel in chat - replaces inline code blocks */
.file-panel {
margin: 8px 0;
padding: 10px 14px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-height: 52px;
max-height: 70px;
display: flex;
align-items: center;
}
.file-panel:hover {
background: #ebebeb;
border-color: #d0d0d0;
}
.file-panel-header {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
width: 100%;
}
.file-icon {
width: 18px;
height: 18px;
opacity: 0.7;
flex-shrink: 0;
margin-left: 16px !important;
}
.file-name {
font-weight: 600;
color: #333;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
}
.file-panel .file-view-button {
padding: 8px 16px;
background: #fff;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 13px;
color: #555;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
font-weight: 500;
margin-right: 16px;
}
.file-view-button:hover {
background: #f5f5f5;
border-color: #b0b0b0;
}
.file-panel.active {
background: #e3f2fd;
border-color: #2196f3;
}
.file-panel.active .file-view-button {
background: #2196f3;
color: #fff;
border-color: #2196f3;
}
/* Empty state for code panel */
.code-panel-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
text-align: center;
padding: 32px;
}
.code-panel-empty svg {
width: 64px;
height: 64px;
opacity: 0.3;
margin-bottom: 16px;
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.chat-code-wrapper {
flex-direction: column !important;
}
.chat-code-wrapper > .chat-wrapper,
.chat-code-wrapper.code-panel-open > .chat-wrapper {
max-width: 100%;
flex: 1;
min-height: 50vh;
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.chat-code-wrapper.code-panel-open .code-panel {
flex: 0 0 50vh;
width: 100%;
min-height: 50vh;
}
}
/* Dark theme support */
html[style*="color-scheme: dark"] .chat-wrapper {
border-right-color: #333;
}
html[style*="color-scheme: dark"] .code-panel {
background: #1a1a1a;
}
html[style*="color-scheme: dark"] .code-panel-header {
background: #242424;
border-bottom-color: #333;
}
html[style*="color-scheme: dark"] .code-panel-filename {
color: #e0e0e0;
}
html[style*="color-scheme: dark"] .code-copy-button {
background: #2a2a2a;
border-color: #444;
color: #e0e0e0;
}
html[style*="color-scheme: dark"] .code-copy-button:hover {
background: #333;
border-color: #555;
}
html[style*="color-scheme: dark"] .code-panel-content pre {
background: #242424;
border-color: #333;
color: #e0e0e0;
}
html[style*="color-scheme: dark"] .code-line-numbers {
background: #1e1e1e;
border-right-color: #333;
}
html[style*="color-scheme: dark"] .file-panel {
background: #2a2a2a;
border-color: #333;
}
html[style*="color-scheme: dark"] .file-panel:hover {
background: #333;
border-color: #444;
}
html[style*="color-scheme: dark"] .file-name {
color: #e0e0e0;
}
html[style*="color-scheme: dark"] .file-view-button {
background: #333;
border-color: #444;
color: #aaa;
}
html[style*="color-scheme: dark"] .file-panel.active {
background: #1e3a5f;
border-color: #2196f3;
}
html[style*="color-scheme: dark"] .code-panel-empty {
color: #666;
}
html[style*="color-scheme: dark"] .code-conversion-toggle {
color: var(--sds-color-text-01);
}
html[style*="color-scheme: dark"] .code-conversion-toggle:hover {
background-color: var(--theme-col-bg-button-ghostsecondary-hover);
border-color: var(--theme-col-border-button-ghostsecondary-hover);
background: rgba(255, 255, 255, 0.05);
}
html[style*="color-scheme: dark"] .toggle-switch {
background: #555;
}
html[style*="color-scheme: dark"] .code-conversion-toggle.enabled .toggle-switch {
background: #2196f3;
}
/* Dark theme: VS Code "Dark Modern"-style colors in code panel */
html[style*="color-scheme: dark"] .code-panel-content pre[class*="language-"],
html[style*="color-scheme: dark"] .code-panel-content code[class*="language-"],
html[style*="color-scheme: dark"] pre.duck-code-block,
html[style*="color-scheme: dark"] .duck-code-block code[class*="language-"] {
background: #1e1e1e !important; /* editor background */
color: #d4d4d4 !important; /* editor foreground */
text-shadow: none !important;
}
/* Make the content wrapper match so gaps/empty areas are the same color */
html[style*="color-scheme: dark"] .code-panel-content .code-content-wrapper,
html[style*="color-scheme: dark"] .duck-code-block .duck-code-content {
background: #1e1e1e !important;
}
/* Dark theme: style the header area */
html[style*="color-scheme: dark"] .duck-code-block .duck-code-header {
background: #1e1e1e !important;
color: #d4d4d4 !important;
}
html[style*="color-scheme: dark"] .duck-code-block .duck-code-lang-label {
color: #d4d4d4 !important;
}
html[style*="color-scheme: dark"] .duck-code-block .duck-code-lang-container svg {
color: #d4d4d4 !important;
}
/* Comments */
html[style*="color-scheme: dark"] .code-panel-content .token.comment,
html[style*="color-scheme: dark"] .code-panel-content .token.prolog,
html[style*="color-scheme: dark"] .code-panel-content .token.doctype,
html[style*="color-scheme: dark"] .code-panel-content .token.cdata,
html[style*="color-scheme: dark"] .duck-code-block .token.comment,
html[style*="color-scheme: dark"] .duck-code-block .token.prolog,
html[style*="color-scheme: dark"] .duck-code-block .token.doctype,
html[style*="color-scheme: dark"] .duck-code-block .token.cdata {
color: #6a9955 !important;
}
/* Keywords and control flow */
html[style*="color-scheme: dark"] .code-panel-content .token.keyword,
html[style*="color-scheme: dark"] .code-panel-content .token.operator,
html[style*="color-scheme: dark"] .duck-code-block .token.keyword,
html[style*="color-scheme: dark"] .duck-code-block .token.operator {
color: #c586c0 !important;
}
/* Strings */
html[style*="color-scheme: dark"] .code-panel-content .token.string,
html[style*="color-scheme: dark"] .code-panel-content .token.char,
html[style*="color-scheme: dark"] .code-panel-content .token.attr-value,
html[style*="color-scheme: dark"] .code-panel-content .token.builtin,
html[style*="color-scheme: dark"] .duck-code-block .token.string,
html[style*="color-scheme: dark"] .duck-code-block .token.char,
html[style*="color-scheme: dark"] .duck-code-block .token.attr-value,
html[style*="color-scheme: dark"] .duck-code-block .token.builtin,
html[style*="color-scheme: dark"] .duck-code-block .token.triple-quoted-string,
html[style*="color-scheme: dark"] .duck-code-block .token.string-interpolation .token.string {
color: #ce9178 !important;
}
/* Functions / methods */
html[style*="color-scheme: dark"] .code-panel-content .token.function,
html[style*="color-scheme: dark"] .code-panel-content .token.method,
html[style*="color-scheme: dark"] .duck-code-block .token.function,
html[style*="color-scheme: dark"] .duck-code-block .token.method {
color: #dcdcaa !important;
}
/* Numbers, booleans, constants */
html[style*="color-scheme: dark"] .code-panel-content .token.number,
html[style*="color-scheme: dark"] .code-panel-content .token.boolean,
html[style*="color-scheme: dark"] .code-panel-content .token.constant,
html[style*="color-scheme: dark"] .duck-code-block .token.number,
html[style*="color-scheme: dark"] .duck-code-block .token.boolean,
html[style*="color-scheme: dark"] .duck-code-block .token.constant {
color: #b5cea8 !important;
}
/* Properties, variables, classes */
html[style*="color-scheme: dark"] .code-panel-content .token.property,
html[style*="color-scheme: dark"] .code-panel-content .token.class-name,
html[style*="color-scheme: dark"] .code-panel-content .token.variable,
html[style*="color-scheme: dark"] .duck-code-block .token.property,
html[style*="color-scheme: dark"] .duck-code-block .token.class-name,
html[style*="color-scheme: dark"] .duck-code-block .token.variable {
color: #9cdcfe !important;
}
/* Punctuation and symbols */
html[style*="color-scheme: dark"] .code-panel-content .token.punctuation,
html[style*="color-scheme: dark"] .code-panel-content .token.symbol,
html[style*="color-scheme: dark"] .duck-code-block .token.punctuation,
html[style*="color-scheme: dark"] .duck-code-block .token.symbol,
html[style*="color-scheme: dark"] .duck-code-block .token.interpolation .token.punctuation {
color: #d4d4d4 !important;
}
/* Override Prism's semi-transparent background on some tokens */
html[style*="color-scheme: dark"] .code-panel-content .language-css .token.string,
html[style*="color-scheme: dark"] .code-panel-content .style .token.string,
html[style*="color-scheme: dark"] .code-panel-content .token.entity,
html[style*="color-scheme: dark"] .code-panel-content .token.operator,
html[style*="color-scheme: dark"] .code-panel-content .token.url,
html[style*="color-scheme: dark"] .duck-code-block .language-css .token.string,
html[style*="color-scheme: dark"] .duck-code-block .style .token.string,
html[style*="color-scheme: dark"] .duck-code-block .token.entity,
html[style*="color-scheme: dark"] .duck-code-block .token.operator,
html[style*="color-scheme: dark"] .duck-code-block .token.url {
background: none !important;
}
/* Dark theme: better contrast for red-ish tokens */
html[style*="color-scheme: dark"] .code-panel-content .token.boolean,
html[style*="color-scheme: dark"] .code-panel-content .token.constant,
html[style*="color-scheme: dark"] .code-panel-content .token.deleted,
html[style*="color-scheme: dark"] .code-panel-content .token.number,
html[style*="color-scheme: dark"] .code-panel-content .token.property,
html[style*="color-scheme: dark"] .code-panel-content .token.symbol,
html[style*="color-scheme: dark"] .code-panel-content .token.tag,
html[style*="color-scheme: dark"] .duck-code-block .token.boolean,
html[style*="color-scheme: dark"] .duck-code-block .token.constant,
html[style*="color-scheme: dark"] .duck-code-block .token.deleted,
html[style*="color-scheme: dark"] .duck-code-block .token.number,
html[style*="color-scheme: dark"] .duck-code-block .token.property,
html[style*="color-scheme: dark"] .duck-code-block .token.symbol,
html[style*="color-scheme: dark"] .duck-code-block .token.tag {
color: #f44747 !important; /* VS Code Dark Modern–style red */
}
`;
document.head.appendChild(style);
}
function injectPrismCSS() {
const style = document.createElement('style');
style.textContent = `
/* ==================== PRISM INLINE CSS ==================== */
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
`;
document.head.appendChild(style);
}
function setupCodePanel() {
// Wait for the app container to exist and have children
const checkApp = setInterval(() => {
const app = document.getElementById('app');
if (app && app.children.length > 0) {
clearInterval(checkApp);
// Find the main content area (not the sidebar)
// The structure is: #app > [sidebar, main content]
// We want to wrap the main content and add our code panel
const mainContent = Array.from(app.children).find(child =>
!child.querySelector('[class*="sidebar"]') &&
child.offsetWidth > 300
);
if (mainContent) {
createCodePanel(mainContent);
} else {
// Fallback: wrap everything except first child (sidebar)
setTimeout(() => {
const children = Array.from(app.children);
if (children.length >= 2) {
createCodePanel(children[1]);
} else if (children.length === 1) {
createCodePanel(children[0]);
}
}, 500);
}
}
}, 100);
}
function createCodePanel(mainContent) {
const app = document.getElementById('app');
// Create wrapper for split view
const wrapper = document.createElement('div');
wrapper.className = 'chat-code-wrapper';
// Create chat wrapper
const chatWrapper = document.createElement('div');
chatWrapper.className = 'chat-wrapper';
// Move main content into chat wrapper
app.insertBefore(wrapper, mainContent);
chatWrapper.appendChild(mainContent);
wrapper.appendChild(chatWrapper);
// Create code panel
const codePanel = document.createElement('div');
codePanel.className = 'code-panel';
codePanel.innerHTML = `
<div class="code-panel-header" id="code-header">
<span class="code-panel-filename" id="current-filename">No file selected</span>
<div class="code-panel-actions">
<button class="code-copy-button" id="copy-button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy code
</button>
<button class="code-close-button" id="close-button" title="Close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="code-panel-content" id="code-content">
<div class="code-panel-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
<div>Select a file to view code</div>
</div>
</div>
`;
wrapper.appendChild(codePanel);
// Initialize code panel manager
initCodePanelManager();
}
function initCodePanelManager() {
const manager = {
currentFile: null,
files: new Map(),
prismLoaded: false,
prismLanguages: new Set(['markup', 'css', 'clike', 'javascript']), // Default languages
setupButtons() {
const copyBtn = document.getElementById('copy-button');
const closeBtn = document.getElementById('close-button');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
if (this.currentFile) {
navigator.clipboard.writeText(this.currentFile.content);
copyBtn.classList.add('copied');
copyBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
Copied!
`;
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Copy code
`;
}, 2000);
}
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => {
this.closePanel();
});
}
},
closePanel() {
const wrapper = document.querySelector('.chat-code-wrapper');
if (wrapper) {
wrapper.classList.remove('code-panel-open');
}
// Remove active state from all file panels and reset button text
document.querySelectorAll('.file-panel').forEach(p => {
p.classList.remove('active');
const btn = p.querySelector('.file-view-button');
if (btn) btn.textContent = 'View';
});
this.currentFile = null;
// Restore sidebar if it was open before
restoreSidebarIfNeeded();
},
revertAllFilePanels() {
// Find all file panels and convert them back to code blocks
const filePanels = document.querySelectorAll('.file-panel');
filePanels.forEach(panel => {
// Get the file data from our map
const fileData = Array.from(this.files.values()).find(f => f.element === panel);
if (fileData) {
// Determine language from extension
const ext = fileData.filename.split('.').pop().toLowerCase();
const langMap = {
'js': 'javascript',
'ts': 'typescript',
'py': 'python',
'html': 'html',
'css': 'css',
'json': 'json',
'jsx': 'jsx',
'tsx': 'tsx',
'java': 'java',
'cpp': 'cpp',
'c': 'c',
'cs': 'csharp',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'php': 'php',
'xml': 'xml',
'yml': 'yaml',
'yaml': 'yaml',
'md': 'markdown',
'sh': 'bash',
'sql': 'sql'
};
const language = langMap[ext] || 'python';
// Create the Duck.ai-style code block structure
const pre = document.createElement('pre');
pre.className = 'duck-code-block';
// Header
const header = document.createElement('div');
header.className = 'duck-code-header';
// Language container
const langContainer = document.createElement('div');
langContainer.className = 'duck-code-lang-container';
const icon = document.createElement('i');
icon.innerHTML = '<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M15.434 7.51c.137.137.212.311.212.49a.694.694 0 0 1-.212.5l-3.54 3.5a.893.893 0 0 1-.277.18 1.024 1.024 0 0 1-.684.038.945.945 0 0 1-.302-.148.787.787 0 0 1-.213-.234.652.652 0 0 1-.045-.58.74.74 0 0 1 .175-.256l3.045-3-3.045-3a.69.69 0 0 1-.22-.55.723.723 0 0 1 .303-.52 1 1 0 0 1 .648-.186.962.962 0 0 1 .614.256l3.541 3.51Zm-12.281 0A.695.695 0 0 0 2.94 8a.694.694 0 0 0 .213.5l3.54 3.5a.893.893 0 0 0 .277.18 1.024 1.024 0 0 0 .684.038.945.945 0 0 0 .302-.148.788.788 0 0 0 .213-.234.651.651 0 0 0 .045-.58.74.74 0 0 0-.175-.256L4.994 8l3.045-3a.69.69 0 0 0 .22-.55.723.723 0 0 0-.303-.52 1 1 0 0 0-.648-.186.962.962 0 0 0-.615.256l-3.54 3.51Z"></path></svg>';
const langLabel = document.createElement('p');
langLabel.className = 'duck-code-lang-label';
langLabel.textContent = language;
langContainer.appendChild(icon);
langContainer.appendChild(langLabel);
header.appendChild(langContainer);
/* Can't work due to needing to be event bound to duck.ai events, so just hide it
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.className = 'duck-code-button-container';
const copyButton = document.createElement('button');
copyButton.type = 'button';
copyButton.className = 'duck-code-copy-btn';
copyButton.setAttribute('data-copycode', 'true');
copyButton.setAttribute('aria-label', 'Copy Code');
copyButton.innerHTML = '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M9.975 1h.09a3.2 3.2 0 0 1 3.202 3.201v1.924a.754.754 0 0 1-.017.16l1.23 1.353A2 2 0 0 1 15 8.983V14a2 2 0 0 1-2 2H8a2 2 0 0 1-1.733-1H4.183a3.201 3.201 0 0 1-3.2-3.201V4.201a3.2 3.2 0 0 1 3.04-3.197A1.25 1.25 0 0 1 5.25 0h3.5c.604 0 1.109.43 1.225 1ZM4.249 2.5h-.066a1.7 1.7 0 0 0-1.7 1.701v7.598c0 .94.761 1.701 1.7 1.701H6V7a2 2 0 0 1 2-2h3.197c.195 0 .387.028.57.083v-.882A1.7 1.7 0 0 0 10.066 2.5H9.75c-.228.304-.591.5-1 .5h-3.5c-.41 0-.772-.196-1-.5ZM5 1.75v-.5A.25.25 0 0 1 5.25 1h3.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-3.5A.25.25 0 0 1 5 1.75ZM7.5 7a.5.5 0 0 1 .5-.5h3V9a1 1 0 0 0 1 1h1.5v4a.5.5 0 0 1-.5.5H8a.5.5 0 0 1-.5-.5V7Zm6 2v-.017a.5.5 0 0 0-.13-.336L12 7.14V9h1.5Z"></path></svg>Copy Code';
const copiedButton = document.createElement('button');
copiedButton.type = 'button';
copiedButton.className = 'duck-code-copied-btn';
copiedButton.innerHTML = '<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" fill-rule="evenodd" d="M20.618 4.214a1 1 0 0 1 .168 1.404l-11 14a1 1 0 0 1-1.554.022l-5-6a1 1 0 0 1 1.536-1.28l4.21 5.05L19.213 4.382a1 1 0 0 1 1.404-.168Z" clip-rule="evenodd"></path></svg>Copied';
buttonContainer.appendChild(copyButton);
buttonContainer.appendChild(copiedButton);
header.appendChild(buttonContainer);
*/
// Code content
const codeContainer = document.createElement('div');
codeContainer.className = 'duck-code-content';
const code = document.createElement('code');
code.className = `language-${language}`;
code.textContent = fileData.content;
codeContainer.appendChild(code);
// Assemble
pre.appendChild(header);
pre.appendChild(codeContainer);
// Replace the file panel with the pre element
const parent = panel.parentNode;
parent.replaceChild(pre, panel);
// Apply Prism syntax highlighting if available
if (window.Prism && Prism.languages[language]) {
Prism.highlightElement(code);
}
}
});
// Clear the files map since we've reverted everything
this.files.clear();
},
observeChatForCode() {
const target = document.querySelector('.chat-wrapper');
if (!target) return;
// timestamps on existing assistant messages
scheduleTimestamps(target);
// Observer 1: childList — catches new nodes being added to the DOM
const childObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.hasAttribute && node.hasAttribute('data-streamdown')) {
this.convertCodeBlocks(node.parentNode);
} else if (node.tagName === 'PRE') {
this.convertCodeBlocks(node.parentNode);
} else {
this.convertCodeBlocks(node);
}
scheduleTimestamps(target);
}
}
});
childObserver.observe(target, {
childList: true,
subtree: true
});
// Observer 2: attributes — watches for data-activeresponse flipping to "true"
// which signals that the assistant message (and its code blocks) are complete.
const attrObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (
m.type === 'attributes' &&
m.attributeName === 'data-activeresponse' &&
m.target.getAttribute('data-activeresponse') === 'true'
) {
// The response is now complete — convert any pending code blocks
// inside this specific assistant message node
this.convertCodeBlocks(m.target);
scheduleTimestamps(target);
}
}
});
attrObserver.observe(target, {
attributes: true,
attributeFilter: ['data-activeresponse'],
subtree: true
});
},
async convertCodeBlocks(element) {
// Check if code conversion is enabled
if (!codeConversionEnabled) {
return;
}
// Configuration: minimum lines required to convert to file panel
const MIN_LINES_FOR_FILE_PANEL = 5;
// Find all code blocks that haven't been converted
// New structure: div[data-streamdown="code-block"]
// Old structure: pre (not inside data-streamdown)
const newCodeBlocks = element.querySelectorAll('div[data-streamdown="code-block"]:not(.file-panel-converted)');
const oldCodeBlocks = element.querySelectorAll('pre:not(.file-panel-converted):not([data-streamdown])');
const allCodeBlocks = [...newCodeBlocks, ...oldCodeBlocks];
for (const block of allCodeBlocks) {
// Mark as converted
const parentResponse = block.closest('[data-activeresponse]');
const isComplete = !parentResponse || parentResponse.getAttribute('data-activeresponse') === 'true';
if (!isComplete) {
// Don't mark it yet — we'll pick it up when data-activeresponse flips
continue;
}
block.classList.add('file-panel-converted');
let code, detectedLang;
// Handle new Duck.ai structure (data-streamdown)
if (block.hasAttribute('data-streamdown')) {
// Language is in data-language attribute
detectedLang = block.getAttribute('data-language') || '';
// Code is inside the pre > code within the body
const bodyDiv = block.querySelector('div[data-streamdown="code-block-body"]');
const pre = bodyDiv?.querySelector('pre');
const codeElement = pre?.querySelector('code');
if (!codeElement) continue;
// Extract text while preserving line breaks
// The code has spans with class "block" for each line
const lineSpans = codeElement.querySelectorAll('span.block');
if (lineSpans.length > 0) {
// Extract text from each line span and trim trailing newlines
code = Array.from(lineSpans).map(span => {
// Remove trailing newline/whitespace that's already in the span
return span.textContent.replace(/\n$/, '');
}).join('\n');
} else {
// Fallback to innerText which preserves some whitespace
code = codeElement.innerText || codeElement.textContent;
}
} else {
// Handle old structure
const codeElement = block.querySelector('code') || block;
code = codeElement.textContent;
if (!code.trim()) continue;
// Wait for language detection (old method)
detectedLang = await new Promise((resolve) => {
let tries = 0;
const iv = setInterval(() => {
const lang = findLang(block);
if (lang || tries++ > 10) {
clearInterval(iv);
resolve(lang || '');
}
}, 100);
});
// Fallback: class-based detection
if (!detectedLang) {
const className = codeElement.className;
if (className.includes('language-')) {
const lang = className.match(/language-(\w+)/)?.[1];
if (lang) detectedLang = lang;
}
}
}
if (!code || !code.trim()) continue;
// Count non-empty lines
const lines = code.split('\n').filter(line => line.trim().length > 0);
// Skip conversion if below minimum line threshold
if (lines.length <= MIN_LINES_FOR_FILE_PANEL) {
continue;
}
// Determine file extension
const ext = getExtensionFromLanguage(detectedLang);
const filename = `code.${ext}`;
// Create and replace with file panel
const filePanel = this.createFilePanel(filename, code);
block.parentNode.replaceChild(filePanel, block);
}
},
createFilePanel(filename, content) {
const panel = document.createElement('div');
panel.className = 'file-panel';
panel.innerHTML = `
<div class="file-panel-header">
<svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
<span class="file-name">${escapeHtml(filename)}</span>
<button class="file-view-button">View</button>
</div>
`;
// Store file data
const fileId = `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
this.files.set(fileId, { filename, content, element: panel });
// Add click handler
panel.addEventListener('click', (e) => {
e.preventDefault();
this.showFile(fileId);
});
return panel;
},
showFile(fileId) {
const file = this.files.get(fileId);
if (!file) return;
// Check if clicking on already active panel
const isAlreadyActive = file.element.classList.contains('active');
if (isAlreadyActive) {
// Close the panel if it's already active
this.closePanel();
return;
}
// Check if code panel is currently closed (before opening)
const wrapper = document.querySelector('.chat-code-wrapper');
const codePanelWasClosed = wrapper && !wrapper.classList.contains('code-panel-open');
// Only collapse sidebar if we're opening the code panel for the first time
if (codePanelWasClosed) {
collapseSidebarIfNeeded();
}
// Update active state
document.querySelectorAll('.file-panel').forEach(p => {
p.classList.remove('active');
// Reset all "View" buttons
const btn = p.querySelector('.file-view-button');
if (btn) btn.textContent = 'View';
});
file.element.classList.add('active');
// Update button text to "Close"
const activeBtn = file.element.querySelector('.file-view-button');
if (activeBtn) activeBtn.textContent = 'Close';
// Show code panel by adding class to wrapper
if (wrapper) {
wrapper.classList.add('code-panel-open');
}
// Update code panel
this.currentFile = file;
document.getElementById('current-filename').textContent = file.filename;
const codeContent = document.getElementById('code-content');
const lines = file.content.split('\n');
const lineNumbers = lines.map((_, i) => `<div>${i + 1}</div>`).join('');
codeContent.innerHTML = `
<pre><div class="code-line-numbers">${lineNumbers}</div><div class="code-content-wrapper"><code>${escapeHtml(file.content)}</code></div></pre>
`;
// Apply syntax highlighting
this.applySyntaxHighlighting(file.filename);
},
applySyntaxHighlighting(filename) {
const codeElement = document.querySelector('#code-content code');
if (!codeElement) return;
// Determine language from extension
const ext = filename.split('.').pop().toLowerCase();
const langMap = {
'js': 'javascript',
'ts': 'typescript',
'py': 'python',
'html': 'markup',
'css': 'css',
'json': 'json',
'jsx': 'jsx',
'tsx': 'tsx',
'java': 'java',
'cpp': 'cpp',
'c': 'c',
'cs': 'csharp',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'php': 'php',
'xml': 'markup',
'yml': 'yaml',
'yaml': 'yaml',
'md': 'markdown',
'sh': 'bash',
'sql': 'sql'
};
const language = langMap[ext] || 'javascript';
// Add language class
codeElement.className = `language-${language}`;
// Highlight with Prism
this.highlightCode(language, codeElement);
},
highlightCode(language, codeElement) {
if (!Prism) return;
if (!Prism.languages[language]) {
console.warn(`Prism language "${language}" not loaded`);
return;
}
codeElement.className = `language-${language}`;
Prism.highlightElement(codeElement);
}
};
// Helper function to find code block lang defined by duck.ai
function findLang(pre) {
const header = pre.firstElementChild;
const p = header?.querySelector('p');
return p?.textContent.trim().toLowerCase();
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Helper function to get file extension from language
function getExtensionFromLanguage(lang) {
const extensions = {
'javascript': 'js',
'typescript': 'ts',
'python': 'py',
'java': 'java',
'cpp': 'cpp',
'c': 'c',
'csharp': 'cs',
'ruby': 'rb',
'go': 'go',
'rust': 'rs',
'php': 'php',
'html': 'html',
'css': 'css',
'json': 'json',
'xml': 'xml',
'yaml': 'yml',
'markdown': 'md',
'bash': 'sh',
'shell': 'sh',
'sql': 'sql',
'jsx': 'jsx',
'tsx': 'tsx'
};
return extensions[lang] || 'txt';
}
// Initialize
manager.setupButtons();
manager.observeChatForCode();
// Also scan existing content
setTimeout(() => {
const chatWrapper = document.querySelector('.chat-wrapper');
if (chatWrapper) {
manager.convertCodeBlocks(chatWrapper);
}
}, 1000);
// Make manager accessible globally for toggle button
window.codeManager = manager;
}
})();