1471 lines
36 KiB
HTML
1471 lines
36 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>AMC</title>
|
||
<style>
|
||
:root {
|
||
--bg: #100F0F;
|
||
--surface: #1C1B1A;
|
||
--selection: #282726;
|
||
--fg: #CECDC3;
|
||
--bright: #FFFCF0;
|
||
--dim: #575653;
|
||
--active: #879A39;
|
||
--needs-attention: #D0A215;
|
||
--starting: #4385BE;
|
||
--done: #8B7EC8; /* Soft purple - ready/waiting, not dead */
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
font-family: "Berkeley Mono", "SF Mono", "Cascadia Code", "JetBrains Mono", monospace;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.header {
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--selection);
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--bright);
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.status-counts {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.count-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 12px;
|
||
color: var(--dim);
|
||
}
|
||
|
||
.count-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
|
||
.count-num {
|
||
font-weight: 600;
|
||
color: var(--fg);
|
||
min-width: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.main { padding: 16px 20px; }
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: var(--dim);
|
||
}
|
||
|
||
.empty-state-title {
|
||
font-size: 16px;
|
||
color: var(--fg);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* Status group sections */
|
||
.status-group { margin-bottom: 24px; }
|
||
|
||
.sessions-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.status-group-header {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
padding: 4px 0 8px;
|
||
border-bottom: 1px solid var(--selection);
|
||
margin-bottom: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.status-group-header .count-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
.status-group-header.needs-attention { color: var(--needs-attention); }
|
||
.status-group-header.done { color: var(--done); }
|
||
.status-group-header.active-header { color: var(--active); }
|
||
|
||
/* Session cards */
|
||
.session {
|
||
background: var(--surface);
|
||
border: 1px solid var(--selection);
|
||
border-radius: 8px;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 700px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.session:hover {
|
||
border-color: var(--dim);
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||
}
|
||
|
||
.session.stale { opacity: 0.5; }
|
||
|
||
.session.done-session {
|
||
border-left: 3px solid var(--done);
|
||
}
|
||
|
||
/* Needs attention sessions get a left accent border */
|
||
.session.attention-session {
|
||
border-left: 3px solid var(--needs-attention);
|
||
}
|
||
|
||
.session-header {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--selection);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.session-header .session-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.session-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.session-header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.session-header .dismiss-btn {
|
||
font-size: 16px;
|
||
padding: 2px 8px;
|
||
}
|
||
|
||
.session-chat {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.session-chat::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.session-chat::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.session-chat::-webkit-scrollbar-thumb {
|
||
background: var(--selection);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.chat-msg {
|
||
max-width: 90%;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.chat-msg.user {
|
||
align-self: flex-end;
|
||
background: var(--selection);
|
||
color: var(--bright);
|
||
}
|
||
|
||
.chat-msg.assistant {
|
||
align-self: flex-start;
|
||
background: #2a2a2a;
|
||
color: var(--text);
|
||
}
|
||
|
||
.chat-loading {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
font-style: italic;
|
||
padding: 8px;
|
||
}
|
||
|
||
.session-footer {
|
||
padding: 12px 16px;
|
||
border-top: 1px solid var(--selection);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.session-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* NEEDS ATTENTION pulses — this is what needs your attention */
|
||
.status-dot.needs_attention {
|
||
background: var(--needs-attention);
|
||
animation: pulse-attention 2s ease-in-out infinite;
|
||
}
|
||
|
||
.status-dot.active { background: var(--active); }
|
||
.status-dot.starting { background: var(--starting); }
|
||
.status-dot.done { background: var(--done); }
|
||
.status-dot.unknown { background: var(--dim); }
|
||
|
||
@keyframes pulse-attention {
|
||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(208, 162, 21, 0.4); }
|
||
50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(208, 162, 21, 0); }
|
||
}
|
||
|
||
.session-info { flex: 1; min-width: 0; }
|
||
|
||
.session-label {
|
||
font-weight: 600;
|
||
color: var(--bright);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.session-status {
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
margin-left: 8px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.session-status.attention-text { color: var(--needs-attention); }
|
||
|
||
.session-meta {
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
margin-top: 2px;
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.session-duration { font-variant-numeric: tabular-nums; }
|
||
|
||
.session-preview {
|
||
font-size: 12px;
|
||
color: var(--dim);
|
||
margin-top: 6px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* Question display for needs_attention sessions */
|
||
.question-block {
|
||
margin-top: 10px;
|
||
padding: 10px 12px;
|
||
background: #282726;
|
||
border: 1px solid rgba(208, 162, 21, 0.3);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.question-header {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: var(--needs-attention);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.question-text {
|
||
font-size: 13px;
|
||
color: var(--bright);
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.question-options {
|
||
margin-top: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.question-option {
|
||
font-size: 12px;
|
||
color: var(--fg);
|
||
padding: 6px 10px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--selection);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.question-option-num {
|
||
color: var(--needs-attention);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.question-option-label {
|
||
font-weight: 600;
|
||
color: var(--bright);
|
||
}
|
||
|
||
.question-option-desc {
|
||
color: var(--dim);
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.session-events {
|
||
display: none;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--selection);
|
||
}
|
||
|
||
.session.expanded .session-events { display: block; }
|
||
|
||
.event-line {
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
padding: 2px 0;
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.event-time {
|
||
color: var(--dim);
|
||
flex-shrink: 0;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.event-name { color: var(--fg); }
|
||
|
||
.badge {
|
||
font-size: 10px;
|
||
color: var(--dim);
|
||
background: var(--selection);
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.stale-label { margin-left: auto; }
|
||
|
||
.dismiss-btn {
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
background: none;
|
||
border: 1px solid var(--selection);
|
||
border-radius: 4px;
|
||
padding: 2px 8px;
|
||
cursor: pointer;
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
|
||
.dismiss-btn:hover {
|
||
color: var(--fg);
|
||
border-color: var(--dim);
|
||
}
|
||
|
||
.loading-events {
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
font-style: italic;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
/* Response UI */
|
||
.response-actions {
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--selection);
|
||
}
|
||
|
||
.response-options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.question-block .response-options {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.question-queue-hint {
|
||
margin-top: 8px;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
.response-option-btn {
|
||
font-family: inherit;
|
||
color: var(--bright);
|
||
background: var(--surface);
|
||
border: 1px solid var(--selection);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
text-align: left;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.response-option-btn:hover {
|
||
background: var(--selection);
|
||
border-color: var(--needs-attention);
|
||
}
|
||
|
||
.response-option-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.opt-label-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
}
|
||
|
||
.opt-num {
|
||
color: var(--needs-attention);
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.opt-label {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.opt-desc {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
line-height: 1.4;
|
||
padding-left: 20px;
|
||
}
|
||
|
||
.response-freeform {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.response-input {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
font-family: inherit;
|
||
color: var(--bright);
|
||
background: var(--bg);
|
||
border: 1px solid var(--selection);
|
||
border-radius: 4px;
|
||
padding: 6px 10px;
|
||
outline: none;
|
||
}
|
||
|
||
.response-input:focus {
|
||
border-color: var(--needs-attention);
|
||
}
|
||
|
||
.response-input::placeholder {
|
||
color: var(--dim);
|
||
}
|
||
|
||
.response-send-btn {
|
||
font-size: 12px;
|
||
font-family: inherit;
|
||
color: var(--bg);
|
||
background: var(--needs-attention);
|
||
border: none;
|
||
border-radius: 4px;
|
||
padding: 6px 14px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.response-send-btn:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.response-send-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.response-status {
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.response-status.error {
|
||
color: #D14D41;
|
||
}
|
||
|
||
.response-status.success {
|
||
color: var(--active);
|
||
}
|
||
|
||
/* Modal */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
z-index: 100;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-overlay.open {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 40px 20px;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--selection);
|
||
border-radius: 8px;
|
||
width: 90%;
|
||
max-height: 70vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.modal-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--selection);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--bright);
|
||
}
|
||
|
||
.modal-meta {
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--dim);
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
color: var(--fg);
|
||
}
|
||
|
||
.modal-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 16px 20px;
|
||
border-top: 1px solid var(--selection);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Conversation messages */
|
||
.conversation {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.message {
|
||
padding: 12px 16px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.message.user {
|
||
background: var(--selection);
|
||
margin-left: 40px;
|
||
}
|
||
|
||
.message.assistant {
|
||
background: var(--bg);
|
||
border: 1px solid var(--selection);
|
||
margin-right: 40px;
|
||
}
|
||
|
||
.message-role {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.message.user .message-role {
|
||
color: var(--active);
|
||
}
|
||
|
||
.message.assistant .message-role {
|
||
color: var(--done);
|
||
}
|
||
|
||
.message-content {
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 10px;
|
||
color: var(--dim);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.conversation-loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--dim);
|
||
}
|
||
|
||
/* Modal prompt input */
|
||
.modal-prompt {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.modal-prompt-input {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
color: var(--bright);
|
||
background: var(--bg);
|
||
border: 1px solid var(--selection);
|
||
border-radius: 4px;
|
||
padding: 10px 14px;
|
||
outline: none;
|
||
resize: none;
|
||
min-height: 44px;
|
||
max-height: 120px;
|
||
}
|
||
|
||
.modal-prompt-input:focus {
|
||
border-color: var(--done);
|
||
}
|
||
|
||
.modal-prompt-input::placeholder {
|
||
color: var(--dim);
|
||
}
|
||
|
||
.modal-prompt-send {
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
color: var(--bg);
|
||
background: var(--done);
|
||
border: none;
|
||
border-radius: 4px;
|
||
padding: 10px 18px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
align-self: flex-end;
|
||
}
|
||
|
||
.modal-prompt-send:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.modal-prompt-send:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<span class="header-title">AGENT MISSION CONTROL</span>
|
||
<div class="status-counts" id="counts"></div>
|
||
</div>
|
||
|
||
<div class="main" id="main">
|
||
<div class="empty-state">
|
||
<div class="empty-state-title">No active sessions</div>
|
||
<div>Start a Claude Code session to see it here</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Session Modal -->
|
||
<div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-header">
|
||
<div>
|
||
<div class="modal-title" id="modal-title">Session</div>
|
||
<div class="modal-meta" id="modal-meta"></div>
|
||
</div>
|
||
<button class="modal-close" onclick="closeModal()">×</button>
|
||
</div>
|
||
<div class="modal-body" id="modal-body">
|
||
<div class="conversation-loading">Loading conversation...</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div class="modal-prompt">
|
||
<textarea class="modal-prompt-input" id="modal-prompt-input" placeholder="Send a message..." rows="1"></textarea>
|
||
<button class="modal-prompt-send" id="modal-prompt-send" onclick="sendModalPrompt()">Send</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
var POLL_MS = 3000;
|
||
var TICK_MS = 1000;
|
||
var STALE_MINUTES = 60;
|
||
var API_STATE = '/api/state';
|
||
var API_EVENTS = '/api/events/';
|
||
var API_DISMISS = '/api/dismiss/';
|
||
var API_RESPOND = '/api/respond/';
|
||
var API_CONVERSATION = '/api/conversation/';
|
||
|
||
var state = { sessions: [], server_time: '' };
|
||
var modalSession = null; // Currently open modal session
|
||
var expandedSessions = new Set();
|
||
var eventCache = {};
|
||
var conversationCache = {}; // Cache conversations for cards: { sessionId: [messages] }
|
||
var inputState = {}; // Preserve input values across renders: { sessionId: inputValue }
|
||
var focusedInputSession = null; // Track which session's input has focus
|
||
var isTyping = false; // Flag to prevent render while typing
|
||
|
||
// -- Favicon --
|
||
var faviconCanvas = document.createElement('canvas');
|
||
faviconCanvas.width = 32;
|
||
faviconCanvas.height = 32;
|
||
var faviconCtx = faviconCanvas.getContext('2d');
|
||
var faviconLink = document.querySelector('link[rel="icon"]');
|
||
if (!faviconLink) {
|
||
faviconLink = document.createElement('link');
|
||
faviconLink.rel = 'icon';
|
||
document.head.appendChild(faviconLink);
|
||
}
|
||
|
||
function updateFavicon(attentionCount, doneCount) {
|
||
var ctx = faviconCtx;
|
||
ctx.clearRect(0, 0, 32, 32);
|
||
ctx.beginPath();
|
||
ctx.arc(16, 16, 14, 0, Math.PI * 2);
|
||
if (attentionCount > 0) {
|
||
ctx.fillStyle = '#D0A215';
|
||
} else if (doneCount > 0) {
|
||
ctx.fillStyle = '#879A39';
|
||
} else {
|
||
ctx.fillStyle = '#575653';
|
||
}
|
||
ctx.fill();
|
||
var total = attentionCount + doneCount;
|
||
if (total > 0) {
|
||
ctx.fillStyle = '#FFFCF0';
|
||
ctx.font = 'bold 18px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(total > 9 ? '9+' : String(total), 16, 17);
|
||
}
|
||
faviconLink.href = faviconCanvas.toDataURL('image/png');
|
||
}
|
||
|
||
// -- Helpers --
|
||
function formatDuration(isoStart) {
|
||
if (!isoStart) return '';
|
||
var ms = Date.now() - new Date(isoStart).getTime();
|
||
var s = Math.floor(ms / 1000);
|
||
if (s < 60) return s + 's';
|
||
var m = Math.floor(s / 60);
|
||
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
||
var h = Math.floor(m / 60);
|
||
return h + 'h ' + (m % 60) + 'm';
|
||
}
|
||
|
||
function formatEventTime(iso) {
|
||
if (!iso) return '';
|
||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
function isStale(session) {
|
||
if (session.status !== 'active') return false;
|
||
if (!session.last_event_at) return false;
|
||
return (Date.now() - new Date(session.last_event_at).getTime()) > STALE_MINUTES * 60 * 1000;
|
||
}
|
||
|
||
// -- DOM building --
|
||
function el(tag, attrs, children) {
|
||
var e = document.createElement(tag);
|
||
if (attrs) {
|
||
for (var _i = 0, _a = Object.entries(attrs); _i < _a.length; _i++) {
|
||
var k = _a[_i][0], v = _a[_i][1];
|
||
if (k === 'className') e.className = v;
|
||
else if (k === 'textContent') e.textContent = v;
|
||
else if (k === 'title') e.title = v;
|
||
else if (k.startsWith('data-')) e.setAttribute(k, v);
|
||
else if (k === 'onclick') e.onclick = v;
|
||
else e.setAttribute(k, v);
|
||
}
|
||
}
|
||
if (children) {
|
||
for (var _j = 0; _j < children.length; _j++) {
|
||
var c = children[_j];
|
||
if (typeof c === 'string') e.appendChild(document.createTextNode(c));
|
||
else if (c) e.appendChild(c);
|
||
}
|
||
}
|
||
return e;
|
||
}
|
||
|
||
function buildCountsBadge(color, count, label) {
|
||
return el('div', { className: 'count-badge' }, [
|
||
el('span', { className: 'count-dot', style: 'background:' + color }),
|
||
el('span', { className: 'count-num', textContent: String(count) }),
|
||
' ' + label,
|
||
]);
|
||
}
|
||
|
||
function buildEventLine(evt) {
|
||
return el('div', { className: 'event-line' }, [
|
||
el('span', { className: 'event-time', textContent: formatEventTime(evt.at) }),
|
||
el('span', { className: 'event-name', textContent: evt.event }),
|
||
]);
|
||
}
|
||
|
||
function buildQuestionBlock(questions, sessionId) {
|
||
if (!questions || questions.length === 0) return null;
|
||
|
||
// Only show the FIRST question - they're sequential, not parallel
|
||
var q = questions[0];
|
||
var statusEl = el('div', { className: 'response-status' });
|
||
var optCount = q.options ? q.options.length : 0;
|
||
|
||
var qChildren = [];
|
||
|
||
// Header badge
|
||
if (q.header) {
|
||
qChildren.push(el('div', { className: 'question-header', textContent: q.header }));
|
||
}
|
||
|
||
// Question text
|
||
qChildren.push(el('div', { className: 'question-text', textContent: q.question }));
|
||
|
||
// This question's options
|
||
if (q.options && q.options.length > 0) {
|
||
var optBtns = [];
|
||
for (var j = 0; j < q.options.length; j++) {
|
||
var opt = q.options[j];
|
||
var num = j + 1;
|
||
(function(optNum, optData) {
|
||
var btnChildren = [
|
||
el('div', { className: 'opt-label-row' }, [
|
||
el('span', { className: 'opt-num', textContent: optNum + '.' }),
|
||
el('span', { className: 'opt-label', textContent: optData.label })
|
||
])
|
||
];
|
||
if (optData.description) {
|
||
btnChildren.push(el('div', { className: 'opt-desc', textContent: optData.description }));
|
||
}
|
||
var btn = el('button', {
|
||
className: 'response-option-btn',
|
||
onclick: function(e) {
|
||
e.stopPropagation();
|
||
respondToSession(sessionId, String(optNum), statusEl);
|
||
}
|
||
}, btnChildren);
|
||
optBtns.push(btn);
|
||
})(num, opt);
|
||
}
|
||
qChildren.push(el('div', { className: 'response-options' }, optBtns));
|
||
}
|
||
|
||
// Freeform input for this question
|
||
var input = el('input', {
|
||
className: 'response-input',
|
||
type: 'text',
|
||
placeholder: optCount > 0 ? 'Or type a custom response...' : 'Type your response...'
|
||
});
|
||
if (inputState[sessionId]) {
|
||
input.value = inputState[sessionId];
|
||
}
|
||
input.setAttribute('data-session', sessionId);
|
||
input.onclick = function(e) { e.stopPropagation(); };
|
||
input.onfocus = function() {
|
||
focusedInputSession = sessionId;
|
||
isTyping = true;
|
||
};
|
||
input.onblur = function() {
|
||
if (focusedInputSession === sessionId) {
|
||
focusedInputSession = null;
|
||
}
|
||
isTyping = false;
|
||
};
|
||
input.oninput = function() {
|
||
inputState[sessionId] = input.value;
|
||
isTyping = true;
|
||
};
|
||
|
||
input.onkeydown = function(e) {
|
||
if (e.key === 'Enter' && input.value.trim()) {
|
||
e.stopPropagation();
|
||
delete inputState[sessionId];
|
||
focusedInputSession = null;
|
||
isTyping = false;
|
||
respondToSession(sessionId, input.value, statusEl, true, optCount);
|
||
}
|
||
};
|
||
|
||
var sendBtn = el('button', {
|
||
className: 'response-send-btn',
|
||
textContent: 'Send',
|
||
onclick: function(e) {
|
||
e.stopPropagation();
|
||
if (input.value.trim()) {
|
||
delete inputState[sessionId];
|
||
focusedInputSession = null;
|
||
isTyping = false;
|
||
respondToSession(sessionId, input.value, statusEl, true, optCount);
|
||
}
|
||
}
|
||
});
|
||
|
||
qChildren.push(el('div', { className: 'response-freeform' }, [input, sendBtn]));
|
||
qChildren.push(statusEl);
|
||
|
||
// Show remaining question count if there are more
|
||
if (questions.length > 1) {
|
||
qChildren.push(el('div', {
|
||
className: 'question-queue-hint',
|
||
textContent: '+ ' + (questions.length - 1) + ' more question' + (questions.length > 2 ? 's' : '') + ' after this'
|
||
}));
|
||
}
|
||
|
||
return el('div', { className: 'question-block' }, qChildren);
|
||
}
|
||
|
||
function buildSessionCard(s) {
|
||
var stale = isStale(s);
|
||
var isDone = s.status === 'done';
|
||
var isAttention = s.status === 'needs_attention';
|
||
|
||
var cls = ['session'];
|
||
if (stale) cls.push('stale');
|
||
if (isDone) cls.push('done-session');
|
||
if (isAttention) cls.push('attention-session');
|
||
|
||
// -- HEADER --
|
||
var statusText = s.status;
|
||
if (isAttention) statusText = 'needs attention';
|
||
if (isDone) statusText = 'done';
|
||
if (s.status === 'active') statusText = 'active';
|
||
if (s.status === 'starting') statusText = 'starting';
|
||
|
||
var headerLeft = [
|
||
el('div', { className: 'status-dot ' + s.status }),
|
||
el('span', { className: 'session-label', textContent: s.project }),
|
||
el('span', { className: 'session-status' + (isAttention ? ' attention-text' : ''), textContent: statusText }),
|
||
];
|
||
|
||
var headerRight = [
|
||
el('span', {
|
||
className: 'session-duration',
|
||
'data-started': s.started_at,
|
||
textContent: formatDuration(s.started_at)
|
||
})
|
||
];
|
||
|
||
if (isDone) {
|
||
headerRight.push(el('button', {
|
||
className: 'dismiss-btn',
|
||
textContent: '×',
|
||
title: 'Dismiss session',
|
||
onclick: function(e) { e.stopPropagation(); dismissSession(s.session_id); }
|
||
}));
|
||
}
|
||
|
||
var header = el('div', { className: 'session-header' }, [
|
||
el('div', { className: 'session-top' }, [
|
||
el('div', { className: 'session-header-left' }, headerLeft),
|
||
el('div', { className: 'session-header-right' }, headerRight),
|
||
])
|
||
]);
|
||
|
||
// -- CHAT AREA --
|
||
var chatEl = el('div', { className: 'session-chat', id: 'chat-' + s.session_id });
|
||
|
||
// Use cached conversation if available, otherwise load async
|
||
if (conversationCache[s.session_id]) {
|
||
renderCardChat(chatEl, conversationCache[s.session_id]);
|
||
} else {
|
||
chatEl.appendChild(el('div', { className: 'chat-loading', textContent: 'Loading conversation...' }));
|
||
loadCardConversation(s.session_id, s.project_dir);
|
||
}
|
||
|
||
// -- FOOTER (question block for attention sessions) --
|
||
var footer = null;
|
||
if (isAttention && s.pending_questions) {
|
||
var qBlock = buildQuestionBlock(s.pending_questions, s.session_id);
|
||
if (qBlock) {
|
||
footer = el('div', { className: 'session-footer' }, [qBlock]);
|
||
}
|
||
}
|
||
|
||
var cardChildren = [header, chatEl];
|
||
if (footer) cardChildren.push(footer);
|
||
|
||
return el('div', {
|
||
className: cls.join(' '),
|
||
'data-sid': s.session_id,
|
||
onclick: function() { openModal(s); }
|
||
}, cardChildren);
|
||
}
|
||
|
||
// -- Render --
|
||
function render(force) {
|
||
// Skip render if user is typing (unless forced)
|
||
if (!force && isTyping) {
|
||
return;
|
||
}
|
||
|
||
var sessions = state.sessions || [];
|
||
|
||
var attention = sessions.filter(function(s) { return s.status === 'needs_attention'; });
|
||
var done = sessions.filter(function(s) { return s.status === 'done'; });
|
||
var active = sessions.filter(function(s) { return s.status === 'active' || s.status === 'starting'; });
|
||
|
||
// Clean up inputState for sessions no longer needing attention
|
||
var attentionIds = new Set(attention.map(function(s) { return s.session_id; }));
|
||
Object.keys(inputState).forEach(function(sid) {
|
||
if (!attentionIds.has(sid)) {
|
||
delete inputState[sid];
|
||
if (focusedInputSession === sid) focusedInputSession = null;
|
||
}
|
||
});
|
||
|
||
// Tab title — attention first (needs response), then done, then active
|
||
var parts = [];
|
||
if (attention.length > 0) parts.push(attention.length + ' attention');
|
||
if (done.length > 0) parts.push(done.length + ' done');
|
||
if (active.length > 0) parts.push(active.length + ' active');
|
||
document.title = parts.length ? parts.join(', ') + ' - AMC' : 'AMC';
|
||
|
||
updateFavicon(attention.length, done.length);
|
||
|
||
// Header counts
|
||
var countsEl = document.getElementById('counts');
|
||
countsEl.replaceChildren(
|
||
buildCountsBadge('var(--needs-attention)', attention.length, 'attention'),
|
||
buildCountsBadge('var(--done)', done.length, 'done'),
|
||
buildCountsBadge('var(--active)', active.length, 'active')
|
||
);
|
||
|
||
var mainEl = document.getElementById('main');
|
||
|
||
if (sessions.length === 0) {
|
||
mainEl.replaceChildren(
|
||
el('div', { className: 'empty-state' }, [
|
||
el('div', { className: 'empty-state-title', textContent: 'No active sessions' }),
|
||
el('div', { textContent: 'Start a Claude Code session to see it here' }),
|
||
])
|
||
);
|
||
return;
|
||
}
|
||
|
||
var fragment = document.createDocumentFragment();
|
||
|
||
function byRecent(a, b) {
|
||
return (b.last_event_at || '').localeCompare(a.last_event_at || '');
|
||
}
|
||
|
||
// Section 1: Needs Attention
|
||
if (attention.length > 0) {
|
||
attention.sort(byRecent);
|
||
var header = el('div', { className: 'status-group-header needs-attention' }, [
|
||
el('span', { className: 'count-dot', style: 'background:var(--needs-attention)' }),
|
||
'NEEDS ATTENTION (' + attention.length + ')',
|
||
]);
|
||
var grid = el('div', { className: 'sessions-grid' });
|
||
for (var i = 0; i < attention.length; i++) grid.appendChild(buildSessionCard(attention[i]));
|
||
var group = el('div', { className: 'status-group' }, [header, grid]);
|
||
fragment.appendChild(group);
|
||
}
|
||
|
||
// Section 2: Done
|
||
if (done.length > 0) {
|
||
done.sort(byRecent);
|
||
var header2 = el('div', { className: 'status-group-header done' }, [
|
||
el('span', { className: 'count-dot', style: 'background:var(--done)' }),
|
||
'DONE (' + done.length + ')',
|
||
]);
|
||
var grid2 = el('div', { className: 'sessions-grid' });
|
||
for (var j = 0; j < done.length; j++) grid2.appendChild(buildSessionCard(done[j]));
|
||
var group2 = el('div', { className: 'status-group' }, [header2, grid2]);
|
||
fragment.appendChild(group2);
|
||
}
|
||
|
||
// Section 3: Active
|
||
if (active.length > 0) {
|
||
active.sort(byRecent);
|
||
var header3 = el('div', { className: 'status-group-header active-header' }, [
|
||
el('span', { className: 'count-dot', style: 'background:var(--active)' }),
|
||
'ACTIVE (' + active.length + ')',
|
||
]);
|
||
var grid3 = el('div', { className: 'sessions-grid' });
|
||
for (var k = 0; k < active.length; k++) grid3.appendChild(buildSessionCard(active[k]));
|
||
var group3 = el('div', { className: 'status-group' }, [header3, grid3]);
|
||
fragment.appendChild(group3);
|
||
}
|
||
|
||
mainEl.replaceChildren(fragment);
|
||
|
||
// Restore focus to input if it was focused before render
|
||
if (focusedInputSession) {
|
||
var inputToFocus = document.querySelector('.response-input[data-session="' + focusedInputSession + '"]');
|
||
if (inputToFocus) {
|
||
inputToFocus.focus();
|
||
// Move cursor to end
|
||
var len = inputToFocus.value.length;
|
||
inputToFocus.setSelectionRange(len, len);
|
||
}
|
||
}
|
||
}
|
||
|
||
// -- Actions --
|
||
function toggleSession(sid) {
|
||
if (expandedSessions.has(sid)) {
|
||
expandedSessions.delete(sid);
|
||
} else {
|
||
expandedSessions.add(sid);
|
||
if (!eventCache[sid]) fetchEvents(sid);
|
||
}
|
||
render();
|
||
}
|
||
|
||
async function dismissSession(sid) {
|
||
try {
|
||
await fetch(API_DISMISS + encodeURIComponent(sid), { method: 'POST' });
|
||
state.sessions = (state.sessions || []).filter(function(s) { return s.session_id !== sid; });
|
||
expandedSessions.delete(sid);
|
||
delete eventCache[sid];
|
||
render();
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function respondToSession(sid, text, statusEl, freeform, optionCount) {
|
||
if (!text.trim()) return;
|
||
|
||
// Show sending state
|
||
if (statusEl) {
|
||
statusEl.textContent = 'Sending...';
|
||
statusEl.className = 'response-status';
|
||
}
|
||
|
||
// Disable buttons during send
|
||
var card = document.querySelector('[data-sid="' + sid + '"]');
|
||
if (card) {
|
||
card.querySelectorAll('.response-option-btn, .response-send-btn').forEach(function(btn) {
|
||
btn.disabled = true;
|
||
});
|
||
}
|
||
|
||
try {
|
||
var payload = { text: text };
|
||
if (freeform) {
|
||
payload.freeform = true;
|
||
payload.optionCount = optionCount || 0;
|
||
}
|
||
var res = await fetch(API_RESPOND + encodeURIComponent(sid), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
var data = await res.json();
|
||
|
||
if (data.ok) {
|
||
if (statusEl) {
|
||
statusEl.textContent = 'Sent!';
|
||
statusEl.className = 'response-status success';
|
||
}
|
||
// Trigger a poll to get updated state
|
||
setTimeout(poll, 500);
|
||
} else {
|
||
throw new Error(data.error || 'Unknown error');
|
||
}
|
||
} catch (e) {
|
||
if (statusEl) {
|
||
statusEl.textContent = 'Error: ' + e.message;
|
||
statusEl.className = 'response-status error';
|
||
}
|
||
// Re-enable buttons on error
|
||
if (card) {
|
||
card.querySelectorAll('.response-option-btn, .response-send-btn').forEach(function(btn) {
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchEvents(sid) {
|
||
try {
|
||
var res = await fetch(API_EVENTS + encodeURIComponent(sid));
|
||
if (res.ok) {
|
||
var data = await res.json();
|
||
eventCache[sid] = data.events || [];
|
||
render();
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadCardConversation(sessionId, projectDir) {
|
||
if (conversationCache[sessionId]) return; // Already loaded
|
||
try {
|
||
var url = API_CONVERSATION + encodeURIComponent(sessionId);
|
||
if (projectDir) {
|
||
url += '?project_dir=' + encodeURIComponent(projectDir);
|
||
}
|
||
var res = await fetch(url);
|
||
if (res.ok) {
|
||
var data = await res.json();
|
||
conversationCache[sessionId] = data.messages || [];
|
||
// Update the chat area in the card
|
||
var chatEl = document.getElementById('chat-' + sessionId);
|
||
if (chatEl) {
|
||
renderCardChat(chatEl, conversationCache[sessionId]);
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function renderCardChat(chatEl, messages) {
|
||
chatEl.innerHTML = '';
|
||
if (messages.length === 0) {
|
||
chatEl.appendChild(el('div', { className: 'chat-loading', textContent: 'No messages yet' }));
|
||
return;
|
||
}
|
||
// Show last N messages (most recent at bottom)
|
||
var recent = messages.slice(-20);
|
||
for (var i = 0; i < recent.length; i++) {
|
||
var msg = recent[i];
|
||
var msgEl = el('div', { className: 'chat-msg ' + msg.role });
|
||
// Truncate long messages
|
||
var text = msg.content;
|
||
if (text.length > 500) {
|
||
text = text.substring(0, 500) + '...';
|
||
}
|
||
msgEl.textContent = text;
|
||
chatEl.appendChild(msgEl);
|
||
}
|
||
// Scroll to bottom
|
||
chatEl.scrollTop = chatEl.scrollHeight;
|
||
}
|
||
|
||
// -- Modal --
|
||
function openModal(session) {
|
||
modalSession = session;
|
||
var overlay = document.getElementById('modal-overlay');
|
||
var title = document.getElementById('modal-title');
|
||
var meta = document.getElementById('modal-meta');
|
||
var body = document.getElementById('modal-body');
|
||
var input = document.getElementById('modal-prompt-input');
|
||
|
||
title.textContent = session.project;
|
||
meta.textContent = session.status + ' • ' + formatDuration(session.started_at);
|
||
body.replaceChildren(el('div', { className: 'conversation-loading', textContent: 'Loading conversation...' }));
|
||
input.value = '';
|
||
|
||
overlay.classList.add('open');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// Fetch conversation
|
||
fetchConversation(session.session_id, session.project_dir);
|
||
}
|
||
|
||
function closeModal(event) {
|
||
if (event && event.target !== event.currentTarget) return;
|
||
var overlay = document.getElementById('modal-overlay');
|
||
overlay.classList.remove('open');
|
||
document.body.style.overflow = '';
|
||
modalSession = null;
|
||
}
|
||
|
||
async function fetchConversation(sessionId, projectDir) {
|
||
var body = document.getElementById('modal-body');
|
||
try {
|
||
var res = await fetch(API_CONVERSATION + encodeURIComponent(sessionId) + '?project_dir=' + encodeURIComponent(projectDir));
|
||
if (!res.ok) throw new Error('Failed to load');
|
||
var data = await res.json();
|
||
renderConversation(data.messages || []);
|
||
} catch (e) {
|
||
body.replaceChildren(el('div', { className: 'conversation-loading', textContent: 'Could not load conversation history' }));
|
||
}
|
||
}
|
||
|
||
function renderConversation(messages) {
|
||
var body = document.getElementById('modal-body');
|
||
if (messages.length === 0) {
|
||
body.replaceChildren(el('div', { className: 'conversation-loading', textContent: 'No messages yet' }));
|
||
return;
|
||
}
|
||
|
||
var conv = el('div', { className: 'conversation' });
|
||
|
||
for (var i = 0; i < messages.length; i++) {
|
||
var msg = messages[i];
|
||
var msgEl = el('div', { className: 'message ' + msg.role }, [
|
||
el('div', { className: 'message-role', textContent: msg.role === 'user' ? 'You' : 'Claude' }),
|
||
el('div', { className: 'message-content', textContent: msg.content }),
|
||
el('div', { className: 'message-time', textContent: formatEventTime(msg.timestamp) })
|
||
]);
|
||
conv.appendChild(msgEl);
|
||
}
|
||
|
||
body.replaceChildren(conv);
|
||
body.scrollTop = body.scrollHeight;
|
||
}
|
||
|
||
async function sendModalPrompt() {
|
||
if (!modalSession) return;
|
||
var input = document.getElementById('modal-prompt-input');
|
||
var btn = document.getElementById('modal-prompt-send');
|
||
var text = input.value.trim();
|
||
if (!text) return;
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'Sending...';
|
||
|
||
try {
|
||
var res = await fetch(API_RESPOND + encodeURIComponent(modalSession.session_id), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: text })
|
||
});
|
||
var data = await res.json();
|
||
if (data.ok) {
|
||
input.value = '';
|
||
btn.textContent = 'Sent!';
|
||
setTimeout(function() {
|
||
btn.textContent = 'Send';
|
||
btn.disabled = false;
|
||
}, 1000);
|
||
} else {
|
||
throw new Error(data.error || 'Send failed');
|
||
}
|
||
} catch (e) {
|
||
btn.textContent = 'Error';
|
||
setTimeout(function() {
|
||
btn.textContent = 'Send';
|
||
btn.disabled = false;
|
||
}, 2000);
|
||
}
|
||
}
|
||
|
||
// Handle Escape key to close modal
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape' && modalSession) {
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
// Handle Enter in modal prompt
|
||
document.getElementById('modal-prompt-input').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendModalPrompt();
|
||
}
|
||
});
|
||
|
||
// -- Polling --
|
||
async function poll() {
|
||
try {
|
||
var res = await fetch(API_STATE);
|
||
if (res.ok) {
|
||
state = await res.json();
|
||
expandedSessions.forEach(function(sid) { fetchEvents(sid); });
|
||
render();
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// -- Duration ticker --
|
||
function tickTimers() {
|
||
document.querySelectorAll('.session-duration[data-started]').forEach(function(node) {
|
||
node.textContent = formatDuration(node.getAttribute('data-started'));
|
||
});
|
||
}
|
||
|
||
// -- Init --
|
||
poll();
|
||
setInterval(poll, POLL_MS);
|
||
setInterval(tickTimers, TICK_MS);
|
||
</script>
|
||
</body>
|
||
</html>
|