clarc/public/index.html
Jared Miller ef3c66f887
Fix exit plan to send option response type
Exit plan responses should use type 'option' not 'text' since they're
selecting from a predefined set of options.
2026-01-28 13:03:07 -05:00

826 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>claude-remote</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
padding: 0;
overflow-x: hidden;
}
header {
background: #2a2a2a;
padding: 16px;
border-bottom: 2px solid #3a3a3a;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 20px;
font-weight: 600;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #888;
}
.status.connected .status-dot {
background: #4CAF50;
}
.container {
padding: 16px;
max-width: 100%;
}
.section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.session {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.session-header {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
min-height: 44px;
}
.session-info {
flex: 1;
min-width: 0;
}
.session-command {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-cwd {
font-size: 12px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-toggle {
font-size: 18px;
color: #888;
transition: transform 0.2s;
}
.session.expanded .session-toggle {
transform: rotate(180deg);
}
.session-output {
display: none;
padding: 16px;
background: #1a1a1a;
border-top: 1px solid #3a3a3a;
max-height: 400px;
overflow-y: auto;
}
.session.expanded .session-output {
display: block;
}
.terminal {
font-family: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
color: #e0e0e0;
}
.prompt {
background: #2a2a2a;
border: 2px solid #ff9800;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.prompt-text {
font-size: 14px;
margin-bottom: 12px;
line-height: 1.5;
}
.prompt-header {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #ff9800;
}
.prompt-tool {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #888;
}
.prompt-tool-name {
color: #e0e0e0;
}
.prompt-tool-input {
font-size: 13px;
font-family: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace;
color: #888;
margin-bottom: 12px;
word-break: break-all;
}
.prompt-question {
font-size: 14px;
margin-bottom: 16px;
line-height: 1.5;
}
.prompt-options {
margin-bottom: 16px;
}
.prompt-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #3a3a3a;
border-radius: 6px;
margin-bottom: 8px;
min-height: 48px;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.prompt-option:hover {
background: rgba(255, 255, 255, 0.05);
}
.prompt-option input[type="radio"],
.prompt-option input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.prompt-option-label {
flex: 1;
font-size: 14px;
line-height: 1.5;
cursor: pointer;
}
.prompt-other-input {
width: 100%;
padding: 12px;
margin-top: 8px;
border: 1px solid #3a3a3a;
border-radius: 6px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 14px;
font-family: inherit;
}
.prompt-other-input:focus {
outline: none;
border-color: #ff9800;
}
.prompt-actions {
display: flex;
gap: 8px;
}
.btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
min-height: 48px;
transition: opacity 0.2s;
}
.btn:active {
opacity: 0.7;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-approve {
background: #4CAF50;
color: white;
}
.btn-reject {
background: #f44336;
color: white;
}
.btn-submit {
background: #ff9800;
color: white;
}
.btn-secondary {
background: #3a3a3a;
color: #e0e0e0;
position: relative;
}
.btn-secondary[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 8px 12px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 12px;
white-space: nowrap;
border-radius: 4px;
border: 1px solid #3a3a3a;
z-index: 1000;
}
.empty-state {
padding: 32px 16px;
text-align: center;
color: #888;
font-size: 14px;
}
@media (prefers-color-scheme: light) {
body {
background: #f5f5f5;
color: #1a1a1a;
}
header {
background: white;
border-bottom-color: #e0e0e0;
}
.session,
.prompt {
background: white;
border-color: #ff9800;
}
.session-output {
background: #f5f5f5;
border-top-color: #e0e0e0;
}
.terminal {
color: #1a1a1a;
}
.prompt-option {
border-color: #e0e0e0;
}
.prompt-option:hover {
background: rgba(0, 0, 0, 0.05);
}
.prompt-other-input {
background: white;
border-color: #e0e0e0;
color: #1a1a1a;
}
.prompt-tool-input {
color: #666;
}
}
</style>
</head>
<body>
<header>
<h1>claude-remote</h1>
<div class="status" id="status">
<span class="status-dot"></span>
<span class="status-text">connecting</span>
</div>
</header>
<div class="container">
<div class="section">
<div class="section-title">Active Sessions</div>
<div id="sessions">
<div class="empty-state">No active sessions</div>
</div>
</div>
<div class="section">
<div class="section-title">Pending Prompts</div>
<div id="prompts">
<div class="empty-state">No pending prompts</div>
</div>
</div>
</div>
<script>
const state = {
sessions: new Map(),
prompts: new Map(),
eventSource: null,
reconnectTimeout: null,
};
const $status = document.getElementById('status');
const $sessions = document.getElementById('sessions');
const $prompts = document.getElementById('prompts');
function setStatus(connected) {
if (connected) {
$status.classList.add('connected');
$status.querySelector('.status-text').textContent = 'connected';
} else {
$status.classList.remove('connected');
$status.querySelector('.status-text').textContent = 'connecting';
}
}
function connectSSE() {
if (state.eventSource) {
state.eventSource.close();
}
const es = new EventSource('/events');
es.addEventListener('open', () => {
console.debug('SSE connected');
setStatus(true);
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
});
es.addEventListener('error', () => {
console.debug('SSE error, reconnecting...');
setStatus(false);
es.close();
if (!state.reconnectTimeout) {
state.reconnectTimeout = setTimeout(() => connectSSE(), 2000);
}
});
es.addEventListener('session_start', (e) => {
const data = JSON.parse(e.data);
state.sessions.set(data.session_id, {
id: data.session_id,
cwd: data.cwd,
command: data.command,
output: '',
outputRenderedLength: 0,
expanded: false,
});
renderSessions();
});
es.addEventListener('session_end', (e) => {
const data = JSON.parse(e.data);
state.sessions.delete(data.session_id);
renderSessions();
});
es.addEventListener('output', (e) => {
const data = JSON.parse(e.data);
const session = state.sessions.get(data.session_id);
if (session) {
session.output += data.data;
renderSessionOutput(data.session_id);
}
});
es.addEventListener('prompt', (e) => {
const data = JSON.parse(e.data);
state.prompts.set(data.prompt_id, {
id: data.prompt_id,
session_id: data.session_id,
text: data.prompt_text,
json: data.prompt_json ? JSON.parse(data.prompt_json) : null,
});
renderPrompts();
});
es.addEventListener('prompt_response', (e) => {
const data = JSON.parse(e.data);
state.prompts.delete(data.prompt_id);
renderPrompts();
});
state.eventSource = es;
}
window.toggleSession = (sessionId) => {
const session = state.sessions.get(Number(sessionId));
if (session) {
session.expanded = !session.expanded;
// Reset rendered length when collapsing or expanding (element gets recreated)
session.outputRenderedLength = 0;
renderSessions();
}
};
function renderSessions() {
if (state.sessions.size === 0) {
$sessions.innerHTML = '<div class="empty-state">No active sessions</div>';
return;
}
$sessions.innerHTML = Array.from(state.sessions.values()).map(s => `
<div class="session ${s.expanded ? 'expanded' : ''}" data-session="${s.id}">
<div class="session-header" onclick="toggleSession('${s.id}')">
<div class="session-info">
<div class="session-command">${escapeHtml(s.command || 'claude')}</div>
<div class="session-cwd">${escapeHtml(s.cwd || '~')}</div>
</div>
<div class="session-toggle">▼</div>
</div>
<div class="session-output">
<div class="terminal" id="output-${s.id}">${escapeHtml(s.output)}</div>
</div>
</div>
`).join('');
}
function renderSessionOutput(sessionId) {
const session = state.sessions.get(sessionId);
if (!session) return;
const $output = document.getElementById(`output-${sessionId}`);
if ($output) {
// Append only new content since last render
const newChunk = session.output.slice(session.outputRenderedLength);
if (newChunk) {
$output.textContent += newChunk;
session.outputRenderedLength = session.output.length;
}
if (session.expanded) {
$output.parentElement.scrollTop = $output.parentElement.scrollHeight;
}
}
}
function renderPrompts() {
if (state.prompts.size === 0) {
$prompts.innerHTML = '<div class="empty-state">No pending prompts</div>';
return;
}
$prompts.innerHTML = Array.from(state.prompts.values()).map(p => {
if (p.json) {
return renderRichPrompt(p);
} else {
// Fallback for old-style prompts
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-text">${escapeHtml(p.text)}</div>
<div class="prompt-actions">
<button class="btn btn-approve" onclick="respondToPrompt('${p.id}', 'approve')">Approve</button>
<button class="btn btn-reject" onclick="respondToPrompt('${p.id}', 'reject')">Reject</button>
</div>
</div>
`;
}
}).join('');
}
function renderRichPrompt(p) {
const json = p.json;
const promptType = json.prompt_type;
if (promptType === 'permission') {
return renderPermissionPrompt(p);
} else if (promptType === 'question') {
return renderQuestionPrompt(p);
} else if (promptType === 'exit_plan') {
return renderExitPlanPrompt(p);
} else {
// Unknown type, fallback
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-text">${escapeHtml(p.text)}</div>
<div class="prompt-actions">
<button class="btn btn-approve" onclick="respondToPrompt('${p.id}', 'approve')">Approve</button>
<button class="btn btn-reject" onclick="respondToPrompt('${p.id}', 'reject')">Reject</button>
</div>
</div>
`;
}
}
function renderPermissionPrompt(p) {
const json = p.json;
const toolName = json.tool_name || 'Unknown';
const toolInput = json.tool_input || {};
const options = json.options || [];
const selectedOption = json.selected_option;
const allowsTabInstructions = json.allows_tab_instructions || false;
// Extract relevant tool input info
let toolInputDisplay = '';
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'Read') {
toolInputDisplay = toolInput.file_path || '';
} else if (toolName === 'Bash') {
toolInputDisplay = toolInput.command || '';
} else if (toolInput.path) {
toolInputDisplay = toolInput.path;
}
const optionsHtml = options.map((opt, idx) => `
<label class="prompt-option" onclick="selectOption('${p.id}', ${idx})">
<input type="radio" name="prompt-${p.id}" value="${escapeHtml(opt.value)}" ${opt.value === selectedOption ? 'checked' : ''}>
<span class="prompt-option-label">${escapeHtml(opt.label)}</span>
</label>
`).join('');
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-tool">Tool: <span class="prompt-tool-name">${escapeHtml(toolName)}</span></div>
${toolInputDisplay ? `<div class="prompt-tool-input">${escapeHtml(toolInputDisplay)}</div>` : ''}
<div class="prompt-question">${escapeHtml(p.text)}</div>
<div class="prompt-options">
${optionsHtml}
</div>
<div class="prompt-actions">
<button class="btn btn-submit" onclick="submitRichPrompt('${p.id}')">Submit</button>
${allowsTabInstructions ? '<button class="btn btn-secondary" disabled data-tooltip="Coming soon">Add instructions</button>' : ''}
</div>
</div>
`;
}
function renderQuestionPrompt(p) {
const json = p.json;
const questions = json.questions || [];
const questionsHtml = questions.map((q, qIdx) => {
const header = q.header || '';
const question = q.question || '';
const options = q.options || [];
const multiSelect = q.multi_select || false;
const allowsOther = q.allows_other || false;
const inputType = multiSelect ? 'checkbox' : 'radio';
const inputName = `prompt-${p.id}-${qIdx}`;
const optionsHtml = options.map((opt, idx) => `
<label class="prompt-option" onclick="selectOption('${p.id}', ${idx})">
<input type="${inputType}" name="${inputName}" value="${escapeHtml(opt.value)}">
<span class="prompt-option-label">${escapeHtml(opt.label)}</span>
</label>
`).join('');
const otherHtml = allowsOther ? `
<label class="prompt-option">
<input type="${inputType}" name="${inputName}" value="__other__" onclick="enableOtherInput('${p.id}-${qIdx}')">
<span class="prompt-option-label">Other</span>
</label>
<input type="text" class="prompt-other-input" id="other-${p.id}-${qIdx}" placeholder="Enter your answer..." disabled>
` : '';
return `
${header ? `<div class="prompt-header">${escapeHtml(header)}</div>` : ''}
<div class="prompt-question">${escapeHtml(question)}</div>
<div class="prompt-options">
${optionsHtml}
${otherHtml}
</div>
`;
}).join('');
return `
<div class="prompt" data-prompt="${p.id}">
${questionsHtml}
<div class="prompt-actions">
<button class="btn btn-submit" onclick="submitRichPrompt('${p.id}')">Submit</button>
</div>
</div>
`;
}
function renderExitPlanPrompt(p) {
const json = p.json;
const options = json.options || [];
const optionsHtml = options.map((opt, idx) => `
<label class="prompt-option" onclick="selectOption('${p.id}', ${idx})">
<input type="radio" name="prompt-${p.id}" value="${escapeHtml(opt.value)}">
<span class="prompt-option-label">${escapeHtml(opt.label)}</span>
</label>
`).join('');
return `
<div class="prompt" data-prompt="${p.id}">
<div class="prompt-header">Plan Complete</div>
<div class="prompt-question">${escapeHtml(p.text)}</div>
<div class="prompt-options">
${optionsHtml}
</div>
<div class="prompt-actions">
<button class="btn btn-submit" onclick="submitRichPrompt('${p.id}')">Submit</button>
</div>
</div>
`;
}
window.selectOption = (_promptId, _optionIdx) => {
// Radio buttons and checkboxes handle their own state
// This function is just for accessibility/mobile tap handling
};
window.enableOtherInput = (promptId) => {
const input = document.getElementById(`other-${promptId}`);
if (input) {
input.disabled = false;
input.focus();
}
};
window.submitRichPrompt = async (promptId) => {
promptId = Number(promptId);
const prompt = state.prompts.get(promptId);
if (!prompt || !prompt.json) {
console.error('Invalid prompt');
return;
}
const json = prompt.json;
let response;
if (json.prompt_type === 'permission') {
// Get selected radio button
const selected = document.querySelector(`input[name="prompt-${promptId}"]:checked`);
if (!selected) {
console.error('No option selected');
return;
}
response = {
type: 'option',
value: selected.value,
};
} else if (json.prompt_type === 'question') {
if (json.multi_select) {
// Get all checked checkboxes
const checked = Array.from(document.querySelectorAll(`input[name="prompt-${promptId}"]:checked`));
if (checked.length === 0) {
console.error('No options selected');
return;
}
const values = checked.map(el => {
if (el.value === '__other__') {
const otherInput = document.getElementById(`other-${promptId}`);
return otherInput ? otherInput.value : '';
}
return el.value;
});
response = {
type: 'text',
value: values.join(', '),
};
} else {
// Get selected radio button
const selected = document.querySelector(`input[name="prompt-${promptId}"]:checked`);
if (!selected) {
console.error('No option selected');
return;
}
if (selected.value === '__other__') {
const otherInput = document.getElementById(`other-${promptId}`);
if (!otherInput || !otherInput.value) {
console.error('Please enter a value for "Other"');
return;
}
response = {
type: 'text',
value: otherInput.value,
};
} else {
response = {
type: 'text',
value: selected.value,
};
}
}
} else if (json.prompt_type === 'exit_plan') {
// Get selected radio button
const selected = document.querySelector(`input[name="prompt-${promptId}"]:checked`);
if (!selected) {
console.error('No option selected');
return;
}
response = {
type: 'option',
value: selected.value,
};
}
try {
const res = await fetch(`/api/prompts/${promptId}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(response),
});
if (!res.ok) {
console.error('Failed to submit prompt response:', await res.text());
}
} catch (error) {
console.error('Error submitting prompt response:', error);
}
};
window.respondToPrompt = async (promptId, action) => {
promptId = Number(promptId);
try {
const res = await fetch(`/api/prompts/${promptId}/${action}`, {
method: 'POST',
});
if (!res.ok) {
console.error(`Failed to ${action} prompt:`, await res.text());
}
} catch (error) {
console.error(`Error responding to prompt:`, error);
}
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize
connectSSE();
</script>
</body>
</html>