mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 08:03:46 +00:00
feat(inspector): improve session UI, skills dropdown, and visual polish
- Add delete button on ended sessions (visible on hover) - Darken ended sessions with opacity and "ended" pill badge - Sort ended sessions to bottom of list - Add token usage pill in chat header - Disable input when session ended - Add Official Skills dropdown with SDK and Rivet presets - Format session IDs shorter with full ID on hover - Add arrow icon to "Configure persistence" link - Add agent logo SVGs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ed30b5f068
commit
700f7dbae0
20 changed files with 1861 additions and 317 deletions
|
|
@ -12,23 +12,23 @@
|
|||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
/* Match landing page colors */
|
||||
/* Match landing page colors - high contrast */
|
||||
--bg: #000000;
|
||||
--surface: rgba(255, 255, 255, 0.02);
|
||||
--surface-2: rgba(255, 255, 255, 0.01);
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--border-2: rgba(255, 255, 255, 0.1);
|
||||
--input-bg: rgba(255, 255, 255, 0.02);
|
||||
--input-border: rgba(255, 255, 255, 0.1);
|
||||
--surface: rgba(255, 255, 255, 0.04);
|
||||
--surface-2: rgba(255, 255, 255, 0.02);
|
||||
--border: rgba(255, 255, 255, 0.15);
|
||||
--border-2: rgba(255, 255, 255, 0.12);
|
||||
--input-bg: rgba(255, 255, 255, 0.05);
|
||||
--input-border: rgba(255, 255, 255, 0.18);
|
||||
--text: #ffffff;
|
||||
--text-secondary: #a1a1aa; /* zinc-400 */
|
||||
--muted: #71717a; /* zinc-500 */
|
||||
--muted-2: #52525b; /* zinc-600 */
|
||||
--text-secondary: #d4d4d8; /* zinc-300 */
|
||||
--muted: #a1a1aa; /* zinc-400 */
|
||||
--muted-2: #71717a; /* zinc-500 */
|
||||
--accent: #ff4f00;
|
||||
--accent-hover: #ff6a00;
|
||||
--success: #4ade80; /* green-400 */
|
||||
--warning: #fbbf24; /* amber-400 */
|
||||
--danger: #f87171; /* red-400 */
|
||||
--danger: #dc2626; /* red-600 */
|
||||
--purple: #a78bfa; /* violet-400 */
|
||||
--cyan: #22d3ee; /* cyan-400 */
|
||||
--radius: 8px;
|
||||
|
|
@ -128,6 +128,34 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.header-link svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.header-link:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -322,12 +350,13 @@
|
|||
|
||||
.button.ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.button.ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 79, 0, 0.1);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
|
|
@ -341,7 +370,7 @@
|
|||
}
|
||||
|
||||
.button.small {
|
||||
padding: 6px 12px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +451,25 @@
|
|||
font-size: 10px;
|
||||
}
|
||||
|
||||
.persist-callout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 12px 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.persist-callout svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Main Layout (Connected) */
|
||||
.main-layout {
|
||||
display: grid;
|
||||
|
|
@ -458,6 +506,23 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar-header-actions .button.secondary.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-header-actions .button.secondary.small.active {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-icon-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
|
@ -478,11 +543,9 @@
|
|||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-add-btn {
|
||||
|
|
@ -952,12 +1015,18 @@
|
|||
|
||||
.agent-option-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-option-logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-option-name {
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
|
|
@ -989,6 +1058,12 @@
|
|||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.agent-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
|
|
@ -1019,15 +1094,13 @@
|
|||
}
|
||||
|
||||
.session-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
|
|
@ -1045,6 +1118,15 @@
|
|||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.session-item-id {
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
|
|
@ -1068,12 +1150,30 @@
|
|||
color: var(--accent);
|
||||
}
|
||||
|
||||
.session-item-events {
|
||||
.session-item.ended {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.session-item.ended .session-item-agent {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.session-item-events {
|
||||
color: #a1a1aa;
|
||||
background: #3f3f46;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.session-item-ended {
|
||||
color: var(--danger);
|
||||
color: #fecaca;
|
||||
background: #3f1212;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
|
|
@ -1087,6 +1187,26 @@
|
|||
color: var(--danger);
|
||||
}
|
||||
|
||||
.session-persistence-note {
|
||||
margin: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-persistence-note a {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-persistence-note a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Chat Panel */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
|
|
@ -1107,6 +1227,23 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.error-banner svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1114,17 +1251,44 @@
|
|||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-meta-pill {
|
||||
max-width: 320px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-id-display {
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-id-display:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.no-session-subtext {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.session-agent-display {
|
||||
|
|
@ -1148,6 +1312,14 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.button.ghost.session-ended-status {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #dc2626 !important;
|
||||
opacity: 1 !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
|
@ -1155,6 +1327,9 @@
|
|||
}
|
||||
|
||||
.messages-container:has(.empty-state) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
|
|
@ -1169,34 +1344,44 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
padding: 48px 24px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--muted-2);
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.empty-state-icon.accent {
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
font-size: 13px;
|
||||
max-width: 280px;
|
||||
line-height: 1.5;
|
||||
max-width: 260px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state .button {
|
||||
margin-top: 16px;
|
||||
margin-top: 24px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
|
@ -1273,6 +1458,12 @@
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
.ai-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
|
|
@ -1408,6 +1599,19 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.simple-status-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.simple-status-message.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1429,6 +1633,253 @@
|
|||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Tool Group - Connected Chain */
|
||||
.tool-group-single {
|
||||
margin: 8px 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.tool-group-container {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tool-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.tool-group-header:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-group-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tool-group-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-group-avatar .ai-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tool-group-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-group-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tool-group-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tool-group-container.failed .tool-group-header {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tool-group-container.failed .tool-group-icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tool-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-item-connector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-item-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
border: 2px solid var(--bg);
|
||||
margin-top: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tool-item.failed .tool-item-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.tool-item-line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
background: var(--border);
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.tool-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.tool-item-header:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-item-header:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tool-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tool-item-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-item-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tool-item-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
color: var(--muted);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition), color var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-item-header:hover .tool-item-link {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tool-item-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tool-item-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tool-item.failed .tool-item-header {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tool-item.failed .tool-item-icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.tool-item-body {
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
||||
.tool-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tool-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-code {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-code.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1490,6 +1941,28 @@
|
|||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.thinking-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.thinking-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-avatar-img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.thinking-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1548,6 +2021,7 @@
|
|||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -1598,8 +2072,10 @@
|
|||
}
|
||||
|
||||
.send-button:disabled {
|
||||
background: var(--border-2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--muted-2);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.send-button svg {
|
||||
|
|
@ -1637,6 +2113,14 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.token-pill {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 4px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Setup Row */
|
||||
.setup-row {
|
||||
display: flex;
|
||||
|
|
@ -1884,8 +2368,8 @@
|
|||
height: 16px;
|
||||
padding: 0 5px;
|
||||
margin-left: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
background: #3f3f46;
|
||||
color: #a1a1aa;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
|
|
@ -1972,10 +2456,16 @@
|
|||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.card-actions .button {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -2030,6 +2520,12 @@
|
|||
border-color: rgba(255, 79, 0, 0.3);
|
||||
}
|
||||
|
||||
.event-item.highlighted {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 0;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.event-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
|
|
@ -2412,6 +2908,70 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast notifications - top banner style */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 8px 20px;
|
||||
background: #374151;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
animation: toast-slide-in 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 16px;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
1
frontend/packages/inspector/public/logos/amp.svg
Normal file
1
frontend/packages/inspector/public/logos/amp.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 281 124" fill="#ffffff" xmlns="http://www.w3.org/2000/svg"><path d="M236.014 0C260.431 9.71106e-05 280.602 17.4115 280.603 44.7432C280.602 73.5337 260.065 94.1657 233.52 94.166C224.158 94.166 215.639 92.4222 208.63 88.4902C202.886 85.2698 198.203 80.6054 194.919 74.3379L188.115 121.822L187.946 123.016H174.214L174.448 121.423L191.772 2.49414H205.372L203.937 11.3369C212.143 3.86078 223.2 0.000153635 236.014 0ZM47.082 0.154297C56.4435 0.154297 65.0012 1.8991 72.0488 5.84863C77.8222 9.08305 82.5323 13.7713 85.8271 20.085L88.1201 3.69238L88.2861 2.49316H101.863L89.1611 90.6328L88.9873 91.8262H75.4092L76.7227 82.8555C68.5854 90.4564 57.3981 94.3231 44.5889 94.3232C20.1709 94.3232 0.000167223 76.9087 0 49.5771C0.000149745 20.7854 20.54 0.154871 47.082 0.154297ZM116.234 90.6357L116.061 91.8271H102.485L115.351 3.68555L115.521 2.49414H129.083L116.234 90.6357ZM140.673 90.6357L140.499 91.8271H126.924L139.789 3.68555L139.96 2.49414H153.521L140.673 90.6357ZM177.958 2.49414L165.108 90.6357L164.935 91.8271H151.36L164.225 3.68555L164.396 2.49414H177.958ZM48.4854 11.9844C27.8638 11.985 14.0133 28.3799 14.0127 48.9521C14.0127 57.7907 16.8094 66.1771 22.3145 72.334C27.7973 78.4657 36.0631 82.4932 47.2402 82.4932C67.8534 82.4925 81.7122 65.9487 81.7129 45.3682C81.7129 35.4076 78.2493 27.0792 72.4131 21.2441C66.5794 15.4088 58.2871 11.9844 48.4854 11.9844ZM233.362 11.8291C212.749 11.8297 198.89 28.3716 198.89 48.9521C198.89 58.9123 202.356 67.2403 208.189 73.0742C214.023 78.9107 222.315 82.3358 232.116 82.3359C252.738 82.3355 266.589 65.9407 266.59 45.3682C266.59 36.5296 263.795 28.1424 258.29 21.9863C252.807 15.8551 244.542 11.8291 233.362 11.8291Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
7
frontend/packages/inspector/public/logos/claude.svg
Normal file
7
frontend/packages/inspector/public/logos/claude.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g314">
|
||||
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
2
frontend/packages/inspector/public/logos/openai.svg
Normal file
2
frontend/packages/inspector/public/logos/openai.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>OpenAI icon</title><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v 2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v 2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/packages/inspector/public/logos/opencode.svg
Normal file
1
frontend/packages/inspector/public/logos/opencode.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width='32' height='40' viewBox='0 0 32 40' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1311_94973)'><path d='M24 32H8V16H24V32Z' fill='#4B4646'/><path d='M24 8H8V32H24V8ZM32 40H0V0H32V40Z' fill='#F1ECEC'/></g><defs><clipPath id='clip0_1311_94973'><rect width='32' height='40' fill='white'/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 347 B |
22
frontend/packages/inspector/public/logos/pi.svg
Normal file
22
frontend/packages/inspector/public/logos/pi.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
|
||||
<path fill="#fff" fill-rule="evenodd" d="
|
||||
M165.29 165.29
|
||||
H517.36
|
||||
V400
|
||||
H400
|
||||
V517.36
|
||||
H282.65
|
||||
V634.72
|
||||
H165.29
|
||||
Z
|
||||
M282.65 282.65
|
||||
V400
|
||||
H400
|
||||
V282.65
|
||||
Z
|
||||
"/>
|
||||
<!-- i dot -->
|
||||
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
|
|
@ -50,11 +50,15 @@ type SessionListItem = {
|
|||
sessionId: string;
|
||||
agent: string;
|
||||
ended: boolean;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
const ERROR_TOAST_MS = 6000;
|
||||
const MAX_ERROR_TOASTS = 3;
|
||||
const CREATE_SESSION_SLOW_WARNING_MS = 90_000;
|
||||
const HTTP_ERROR_EVENT = "inspector-http-error";
|
||||
const ARCHIVED_SESSIONS_KEY = "sandbox-agent-inspector-archived-sessions";
|
||||
const SESSION_MODELS_KEY = "sandbox-agent-inspector-session-models";
|
||||
|
||||
const DEFAULT_ENDPOINT = "http://localhost:2468";
|
||||
|
||||
|
|
@ -112,6 +116,29 @@ const getHttpErrorMessage = (status: number, statusText: string, responseBody: s
|
|||
return `${base}: ${clippedBody}`;
|
||||
};
|
||||
|
||||
const shouldIgnoreGlobalError = (value: unknown): boolean => {
|
||||
const name = value instanceof Error ? value.name : "";
|
||||
const message = (() => {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Error) return value.message;
|
||||
if (value && typeof value === "object" && "message" in value && typeof (value as { message?: unknown }).message === "string") {
|
||||
return (value as { message: string }).message;
|
||||
}
|
||||
return "";
|
||||
})().toLowerCase();
|
||||
|
||||
if (name === "AbortError") return true;
|
||||
if (!message) return false;
|
||||
|
||||
return (
|
||||
message.includes("aborterror") ||
|
||||
message.includes("the operation was aborted") ||
|
||||
message.includes("signal is aborted") ||
|
||||
message.includes("resizeobserver loop limit exceeded") ||
|
||||
message.includes("resizeobserver loop completed with undelivered notifications")
|
||||
);
|
||||
};
|
||||
|
||||
const getSessionIdFromPath = (): string => {
|
||||
const basePath = import.meta.env.BASE_URL;
|
||||
const path = window.location.pathname;
|
||||
|
|
@ -120,6 +147,50 @@ const getSessionIdFromPath = (): string => {
|
|||
return match ? match[1] : "";
|
||||
};
|
||||
|
||||
const getArchivedSessionIds = (): Set<string> => {
|
||||
if (typeof window === "undefined") return new Set<string>();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ARCHIVED_SESSIONS_KEY);
|
||||
if (!raw) return new Set<string>();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set<string>();
|
||||
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
};
|
||||
|
||||
const archiveSessionId = (id: string): void => {
|
||||
if (typeof window === "undefined" || !id) return;
|
||||
const archived = getArchivedSessionIds();
|
||||
archived.add(id);
|
||||
window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived]));
|
||||
};
|
||||
|
||||
const unarchiveSessionId = (id: string): void => {
|
||||
if (typeof window === "undefined" || !id) return;
|
||||
const archived = getArchivedSessionIds();
|
||||
if (!archived.delete(id)) return;
|
||||
window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived]));
|
||||
};
|
||||
|
||||
const getPersistedSessionModels = (): Record<string, string> => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SESSION_MODELS_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const updateSessionPath = (id: string) => {
|
||||
const basePath = import.meta.env.BASE_URL;
|
||||
const params = window.location.search;
|
||||
|
|
@ -185,6 +256,7 @@ export default function App() {
|
|||
const [agentId, setAgentId] = useState("claude");
|
||||
const [sessionId, setSessionId] = useState(getSessionIdFromPath());
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
const [sessionModelById, setSessionModelById] = useState<Record<string, string>>(() => getPersistedSessionModels());
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
const [events, setEvents] = useState<SessionEvent[]>([]);
|
||||
|
|
@ -198,12 +270,16 @@ export default function App() {
|
|||
const toastTimeoutsRef = useRef<Map<number, number>>(new Map());
|
||||
|
||||
const [debugTab, setDebugTab] = useState<DebugTab>("events");
|
||||
const [highlightedEventId, setHighlightedEventId] = useState<string | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const clientRef = useRef<SandboxAgent | null>(null);
|
||||
const activeSessionRef = useRef<Session | null>(null);
|
||||
const eventUnsubRef = useRef<(() => void) | null>(null);
|
||||
const reconnectingAfterCreateFailureRef = useRef(false);
|
||||
const creatingSessionRef = useRef(false);
|
||||
const createNoiseIgnoreUntilRef = useRef(0);
|
||||
|
||||
const logRequest = useCallback((entry: RequestLog) => {
|
||||
setRequestLog((prev) => {
|
||||
|
|
@ -360,7 +436,9 @@ export default function App() {
|
|||
}
|
||||
setEvents(allEvents);
|
||||
};
|
||||
hydrateEvents().catch(() => {});
|
||||
hydrateEvents().catch((error) => {
|
||||
console.error("Failed to hydrate events:", error);
|
||||
});
|
||||
|
||||
// Subscribe to new events
|
||||
const unsub = session.onEvent((event) => {
|
||||
|
|
@ -375,6 +453,22 @@ export default function App() {
|
|||
setConnectError(null);
|
||||
}
|
||||
try {
|
||||
// Ensure reconnects do not keep stale session subscriptions/clients around.
|
||||
if (eventUnsubRef.current) {
|
||||
eventUnsubRef.current();
|
||||
eventUnsubRef.current = null;
|
||||
}
|
||||
activeSessionRef.current = null;
|
||||
if (clientRef.current) {
|
||||
try {
|
||||
await clientRef.current.dispose();
|
||||
} catch (disposeError) {
|
||||
console.warn("Failed to dispose previous client during reconnect:", disposeError);
|
||||
} finally {
|
||||
clientRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await createClient(overrideEndpoint);
|
||||
await client.getHealth();
|
||||
if (overrideEndpoint) {
|
||||
|
|
@ -383,6 +477,14 @@ export default function App() {
|
|||
setConnected(true);
|
||||
await refreshAgents();
|
||||
await fetchSessions();
|
||||
if (sessionId) {
|
||||
try {
|
||||
const resumed = await client.resumeSession(sessionId);
|
||||
subscribeToSession(resumed);
|
||||
} catch (resumeError) {
|
||||
console.warn("Failed to resume current session after reconnect:", resumeError);
|
||||
}
|
||||
}
|
||||
if (reportError) {
|
||||
setConnectError(null);
|
||||
}
|
||||
|
|
@ -408,7 +510,9 @@ export default function App() {
|
|||
}
|
||||
activeSessionRef.current = null;
|
||||
if (clientRef.current) {
|
||||
void clientRef.current.dispose();
|
||||
void clientRef.current.dispose().catch((error) => {
|
||||
console.warn("Failed to dispose client on disconnect:", error);
|
||||
});
|
||||
}
|
||||
setConnected(false);
|
||||
clientRef.current = null;
|
||||
|
|
@ -436,12 +540,15 @@ export default function App() {
|
|||
};
|
||||
|
||||
const loadAgentConfig = useCallback(async (targetAgentId: string) => {
|
||||
console.log("[loadAgentConfig] Loading config for agent:", targetAgentId);
|
||||
try {
|
||||
const info = await getClient().getAgent(targetAgentId, { config: true });
|
||||
console.log("[loadAgentConfig] Got agent info:", info);
|
||||
setAgents((prev) =>
|
||||
prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a))
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[loadAgentConfig] Failed to load config:", error);
|
||||
// Config loading is best-effort; the menu still works without it.
|
||||
}
|
||||
}, [getClient]);
|
||||
|
|
@ -450,6 +557,7 @@ export default function App() {
|
|||
setSessionsLoading(true);
|
||||
setSessionsError(null);
|
||||
try {
|
||||
const archivedSessionIds = getArchivedSessionIds();
|
||||
// TODO: This eagerly paginates all sessions so we can reverse-sort to
|
||||
// show newest first. Replace with a server-side descending sort or a
|
||||
// dedicated "recent sessions" query once the API supports it.
|
||||
|
|
@ -462,6 +570,7 @@ export default function App() {
|
|||
sessionId: s.id,
|
||||
agent: s.agent,
|
||||
ended: s.destroyedAt != null,
|
||||
archived: archivedSessionIds.has(s.id),
|
||||
});
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
|
|
@ -475,6 +584,44 @@ export default function App() {
|
|||
}
|
||||
};
|
||||
|
||||
const archiveSession = async (targetSessionId: string) => {
|
||||
archiveSessionId(targetSessionId);
|
||||
try {
|
||||
try {
|
||||
await getClient().destroySession(targetSessionId);
|
||||
} catch (error) {
|
||||
// If the server already considers the session gone, still archive in local UI.
|
||||
console.warn("Destroy session returned an error while archiving:", error);
|
||||
}
|
||||
setSessions((prev) =>
|
||||
prev.map((session) =>
|
||||
session.sessionId === targetSessionId
|
||||
? { ...session, archived: true, ended: true }
|
||||
: session
|
||||
)
|
||||
);
|
||||
setSessionModelById((prev) => {
|
||||
if (!(targetSessionId in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[targetSessionId];
|
||||
return next;
|
||||
});
|
||||
await fetchSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to archive session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const unarchiveSession = async (targetSessionId: string) => {
|
||||
unarchiveSessionId(targetSessionId);
|
||||
setSessions((prev) =>
|
||||
prev.map((session) =>
|
||||
session.sessionId === targetSessionId ? { ...session, archived: false } : session
|
||||
)
|
||||
);
|
||||
await fetchSessions();
|
||||
};
|
||||
|
||||
const installAgent = async (targetId: string, reinstall: boolean) => {
|
||||
try {
|
||||
await getClient().installAgent(targetId, { reinstall });
|
||||
|
|
@ -509,6 +656,12 @@ export default function App() {
|
|||
setSessionId(session.sessionId);
|
||||
updateSessionPath(session.sessionId);
|
||||
setAgentId(session.agent);
|
||||
setSessionModelById((prev) => {
|
||||
if (prev[session.sessionId]) return prev;
|
||||
const fallbackModel = defaultModelByAgent[session.agent];
|
||||
if (!fallbackModel) return prev;
|
||||
return { ...prev, [session.sessionId]: fallbackModel };
|
||||
});
|
||||
setEvents([]);
|
||||
setSessionError(null);
|
||||
|
||||
|
|
@ -521,12 +674,14 @@ export default function App() {
|
|||
};
|
||||
|
||||
const createNewSession = async (nextAgentId: string, config: { agentMode: string; model: string }) => {
|
||||
setAgentId(nextAgentId);
|
||||
console.log("[createNewSession] Creating session for agent:", nextAgentId, "config:", config);
|
||||
setSessionError(null);
|
||||
setEvents([]);
|
||||
creatingSessionRef.current = true;
|
||||
createNoiseIgnoreUntilRef.current = Date.now() + 10_000;
|
||||
|
||||
try {
|
||||
const session = await getClient().createSession({
|
||||
console.log("[createNewSession] Calling createSession...");
|
||||
const createSessionPromise = getClient().createSession({
|
||||
agent: nextAgentId,
|
||||
sessionInit: {
|
||||
cwd: "/",
|
||||
|
|
@ -534,12 +689,31 @@ export default function App() {
|
|||
},
|
||||
});
|
||||
|
||||
let slowWarningShown = false;
|
||||
const slowWarningTimerId = window.setTimeout(() => {
|
||||
slowWarningShown = true;
|
||||
setSessionError("Session creation is taking longer than expected. Waiting for agent startup...");
|
||||
}, CREATE_SESSION_SLOW_WARNING_MS);
|
||||
let session: Awaited<ReturnType<SandboxAgent["createSession"]>>;
|
||||
try {
|
||||
session = await createSessionPromise;
|
||||
} finally {
|
||||
window.clearTimeout(slowWarningTimerId);
|
||||
}
|
||||
console.log("[createNewSession] Session created:", session.id);
|
||||
if (slowWarningShown) {
|
||||
setSessionError(null);
|
||||
}
|
||||
|
||||
setAgentId(nextAgentId);
|
||||
setEvents([]);
|
||||
setSessionId(session.id);
|
||||
updateSessionPath(session.id);
|
||||
subscribeToSession(session);
|
||||
const skipPostCreateConfig = nextAgentId === "opencode";
|
||||
|
||||
// Apply mode if selected
|
||||
if (config.agentMode) {
|
||||
if (!skipPostCreateConfig && config.agentMode) {
|
||||
try {
|
||||
await session.send("session/set_mode", { modeId: config.agentMode });
|
||||
} catch {
|
||||
|
|
@ -549,18 +723,49 @@ export default function App() {
|
|||
|
||||
// Apply model if selected
|
||||
if (config.model) {
|
||||
try {
|
||||
await session.send("unstable/set_session_model", { modelId: config.model });
|
||||
} catch {
|
||||
// Model application is best-effort
|
||||
setSessionModelById((prev) => ({ ...prev, [session.id]: config.model }));
|
||||
if (!skipPostCreateConfig) {
|
||||
try {
|
||||
const agentInfo = agents.find((agent) => agent.id === nextAgentId);
|
||||
const modelOption = ((agentInfo?.configOptions ?? []) as ConfigOption[]).find(
|
||||
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string"
|
||||
);
|
||||
if (modelOption && config.model !== modelOption.currentValue) {
|
||||
await session.send("session/set_config_option", {
|
||||
optionId: modelOption.id,
|
||||
value: config.model,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Model application is best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fetchSessions();
|
||||
// Refresh session list in background; UI should not stay blocked on list pagination.
|
||||
void fetchSessions();
|
||||
} catch (error) {
|
||||
console.error("[createNewSession] Failed to create session:", error);
|
||||
const messageText = getErrorMessage(error, "Unable to create session");
|
||||
console.error("[createNewSession] Error message:", messageText);
|
||||
setSessionError(messageText);
|
||||
pushErrorToast(error, messageText);
|
||||
if (!reconnectingAfterCreateFailureRef.current) {
|
||||
reconnectingAfterCreateFailureRef.current = true;
|
||||
// Run recovery in background so failed creates do not block UI.
|
||||
void connectToDaemon(false)
|
||||
.catch((reconnectError) => {
|
||||
console.error("[createNewSession] Soft reconnect failed:", reconnectError);
|
||||
})
|
||||
.finally(() => {
|
||||
reconnectingAfterCreateFailureRef.current = false;
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
creatingSessionRef.current = false;
|
||||
// Keep a short post-create window for delayed transport rejections.
|
||||
createNoiseIgnoreUntilRef.current = Date.now() + 2_000;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -660,8 +865,13 @@ export default function App() {
|
|||
const params = payload.params as Record<string, unknown> | undefined;
|
||||
const promptArray = params?.prompt as Array<{ type: string; text?: string }> | undefined;
|
||||
const text = promptArray?.[0]?.text ?? "";
|
||||
// Skip session replay context messages
|
||||
if (text.startsWith("Previous session history is replayed below")) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "message",
|
||||
time,
|
||||
role: "user",
|
||||
|
|
@ -684,6 +894,7 @@ export default function App() {
|
|||
assistantAccumText = "";
|
||||
entries.push({
|
||||
id: assistantAccumId,
|
||||
eventId: event.id,
|
||||
kind: "message",
|
||||
time,
|
||||
role: "assistant",
|
||||
|
|
@ -707,6 +918,7 @@ export default function App() {
|
|||
thoughtAccumText = "";
|
||||
entries.push({
|
||||
id: thoughtAccumId,
|
||||
eventId: event.id,
|
||||
kind: "reasoning",
|
||||
time,
|
||||
reasoning: { text: "", visibility: "public" },
|
||||
|
|
@ -726,6 +938,7 @@ export default function App() {
|
|||
const text = content?.type === "text" ? (content.text ?? "") : JSON.stringify(content);
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "message",
|
||||
time,
|
||||
role: "user",
|
||||
|
|
@ -748,6 +961,7 @@ export default function App() {
|
|||
} else {
|
||||
const entry: TimelineEntry = {
|
||||
id: `tool-${toolCallId}`,
|
||||
eventId: event.id,
|
||||
kind: "tool",
|
||||
time,
|
||||
toolName: (update.title as string) ?? "tool",
|
||||
|
|
@ -776,6 +990,7 @@ export default function App() {
|
|||
const detail = planEntries.map((e) => `[${e.status}] ${e.content}`).join("\n");
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: "Plan", detail, severity: "info" },
|
||||
|
|
@ -786,6 +1001,7 @@ export default function App() {
|
|||
const title = update.title as string | undefined;
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: "Session info update", detail: title ? `Title: ${title}` : undefined, severity: "info" },
|
||||
|
|
@ -793,24 +1009,13 @@ export default function App() {
|
|||
break;
|
||||
}
|
||||
case "usage_update": {
|
||||
const size = update.size as number | undefined;
|
||||
const used = update.used as number | undefined;
|
||||
const cost = update.cost as { total?: number } | undefined;
|
||||
const parts = [`${used ?? 0}/${size ?? 0} tokens`];
|
||||
if (cost?.total != null) {
|
||||
parts.push(`cost: $${cost.total.toFixed(4)}`);
|
||||
}
|
||||
entries.push({
|
||||
id: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: "Usage update", detail: parts.join(" | "), severity: "info" },
|
||||
});
|
||||
// Token usage is displayed in the config bar, not in the transcript
|
||||
break;
|
||||
}
|
||||
case "current_mode_update": {
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: "Mode changed", detail: update.currentModeId as string, severity: "info" },
|
||||
|
|
@ -820,6 +1025,7 @@ export default function App() {
|
|||
case "config_option_update": {
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: "Config option update", severity: "info" },
|
||||
|
|
@ -829,6 +1035,7 @@ export default function App() {
|
|||
case "available_commands_update": {
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: "Available commands update", severity: "info" },
|
||||
|
|
@ -838,6 +1045,7 @@ export default function App() {
|
|||
default: {
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: `session/update: ${update.sessionUpdate}`, severity: "info" },
|
||||
|
|
@ -852,6 +1060,7 @@ export default function App() {
|
|||
const params = payload.params as { error?: string; location?: string } | undefined;
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: {
|
||||
|
|
@ -867,6 +1076,7 @@ export default function App() {
|
|||
if (method) {
|
||||
entries.push({
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
kind: "meta",
|
||||
time,
|
||||
meta: { title: method, detail: event.sender, severity: "info" },
|
||||
|
|
@ -887,10 +1097,32 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldIgnoreCreateNoise = (value: unknown): boolean => {
|
||||
if (Date.now() > createNoiseIgnoreUntilRef.current) return false;
|
||||
const message = getErrorMessage(value, "").trim().toLowerCase();
|
||||
return (
|
||||
message.length === 0 ||
|
||||
message === "request failed" ||
|
||||
message.includes("request failed") ||
|
||||
message.includes("unhandled promise rejection")
|
||||
);
|
||||
};
|
||||
|
||||
const handleWindowError = (event: ErrorEvent) => {
|
||||
pushErrorToast(event.error ?? event.message, "Unexpected error");
|
||||
const errorLike = event.error ?? event.message;
|
||||
if (shouldIgnoreCreateNoise(errorLike)) return;
|
||||
if (shouldIgnoreGlobalError(errorLike)) return;
|
||||
pushErrorToast(errorLike, "Unexpected error");
|
||||
};
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
if (shouldIgnoreCreateNoise(event.reason)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (shouldIgnoreGlobalError(event.reason)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
pushErrorToast(event.reason, "Unhandled promise rejection");
|
||||
};
|
||||
const handleHttpError = (event: Event) => {
|
||||
|
|
@ -966,13 +1198,22 @@ export default function App() {
|
|||
// Auto-load session when sessionId changes
|
||||
useEffect(() => {
|
||||
if (!connected || !sessionId) return;
|
||||
const hasSession = sessions.some((s) => s.sessionId === sessionId);
|
||||
if (!hasSession) return;
|
||||
if (creatingSessionRef.current) return;
|
||||
const sessionInfo = sessions.find((s) => s.sessionId === sessionId);
|
||||
if (!sessionInfo) return;
|
||||
if (activeSessionRef.current?.id === sessionId) return;
|
||||
|
||||
// Set the correct agent from the session
|
||||
setAgentId(sessionInfo.agent);
|
||||
// Clear stale events before loading
|
||||
setEvents([]);
|
||||
setSessionError(null);
|
||||
|
||||
getClient().resumeSession(sessionId).then((session) => {
|
||||
subscribeToSession(session);
|
||||
}).catch(() => {});
|
||||
}).catch((error) => {
|
||||
setSessionError(getErrorMessage(error, "Unable to resume session"));
|
||||
});
|
||||
}, [connected, sessionId, sessions, getClient, subscribeToSession]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -981,7 +1222,59 @@ export default function App() {
|
|||
|
||||
const currentAgent = agents.find((agent) => agent.id === agentId);
|
||||
const agentLabel = agentDisplayNames[agentId] ?? agentId;
|
||||
const sessionEnded = sessions.find((s) => s.sessionId === sessionId)?.ended ?? false;
|
||||
const selectedSession = sessions.find((s) => s.sessionId === sessionId);
|
||||
const sessionArchived = selectedSession?.archived ?? false;
|
||||
// Archived sessions are treated as ended in UI so they can never be "ended again".
|
||||
const sessionEnded = (selectedSession?.ended ?? false) || sessionArchived;
|
||||
|
||||
// Determine if agent is thinking (has in-progress tools or waiting for response)
|
||||
const isThinking = useMemo(() => {
|
||||
if (!sessionId || sessionEnded) return false;
|
||||
// If actively sending a prompt, show thinking
|
||||
if (sending) return true;
|
||||
// Check for in-progress tool calls
|
||||
const hasInProgressTool = transcriptEntries.some(
|
||||
(e) => e.kind === "tool" && e.toolStatus === "in_progress"
|
||||
);
|
||||
if (hasInProgressTool) return true;
|
||||
// Check if last message was from user with no subsequent agent activity
|
||||
const lastUserMessageIndex = [...transcriptEntries].reverse().findIndex((e) => e.kind === "message" && e.role === "user");
|
||||
if (lastUserMessageIndex === -1) return false;
|
||||
// If user message is the very last entry, we're waiting for response
|
||||
if (lastUserMessageIndex === 0) return true;
|
||||
// Check if there's any agent response after the user message
|
||||
const entriesAfterUser = transcriptEntries.slice(-(lastUserMessageIndex));
|
||||
const hasAgentResponse = entriesAfterUser.some(
|
||||
(e) => e.kind === "message" && e.role === "assistant"
|
||||
);
|
||||
// If no assistant message after user, but there are completed tools, not thinking
|
||||
const hasCompletedTools = entriesAfterUser.some(
|
||||
(e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed")
|
||||
);
|
||||
if (!hasAgentResponse && !hasCompletedTools) return true;
|
||||
return false;
|
||||
}, [sessionId, sessionEnded, transcriptEntries, sending]);
|
||||
|
||||
// Extract latest token usage from events
|
||||
const tokenUsage = useMemo(() => {
|
||||
let latest: { used: number; size: number; cost?: number } | null = null;
|
||||
for (const event of events) {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const method = typeof payload.method === "string" ? payload.method : null;
|
||||
if (event.sender === "agent" && method === "session/update") {
|
||||
const params = payload.params as Record<string, unknown> | undefined;
|
||||
const update = params?.update as Record<string, unknown> | undefined;
|
||||
if (update?.sessionUpdate === "usage_update") {
|
||||
latest = {
|
||||
used: (update.used as number) ?? 0,
|
||||
size: (update.size as number) ?? 0,
|
||||
cost: (update.cost as { total?: number })?.total,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}, [events]);
|
||||
|
||||
// Extract modes and models from configOptions
|
||||
const modesByAgent = useMemo(() => {
|
||||
|
|
@ -1030,6 +1323,88 @@ export default function App() {
|
|||
return result;
|
||||
}, [agents]);
|
||||
|
||||
const currentSessionModelId = useMemo(() => {
|
||||
let latestModelId: string | null = null;
|
||||
|
||||
for (const event of events) {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const method = typeof payload.method === "string" ? payload.method : null;
|
||||
const params = payload.params as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.sender === "agent" && method === "session/update") {
|
||||
const update = params?.update as Record<string, unknown> | undefined;
|
||||
if (update?.sessionUpdate !== "config_option_update") continue;
|
||||
|
||||
const category = (update.category as string | undefined)
|
||||
?? ((update.option as Record<string, unknown> | undefined)?.category as string | undefined);
|
||||
if (category && category !== "model") continue;
|
||||
|
||||
const optionId = (update.optionId as string | undefined)
|
||||
?? (update.configOptionId as string | undefined)
|
||||
?? ((update.option as Record<string, unknown> | undefined)?.id as string | undefined);
|
||||
const seemsModelOption = !optionId || optionId.toLowerCase().includes("model");
|
||||
if (!seemsModelOption) continue;
|
||||
|
||||
const candidate = (update.value as string | undefined)
|
||||
?? (update.currentValue as string | undefined)
|
||||
?? (update.selectedValue as string | undefined)
|
||||
?? (update.modelId as string | undefined);
|
||||
if (candidate) {
|
||||
latestModelId = candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Capture explicit client-side model changes; these are persisted and survive refresh.
|
||||
if (event.sender === "client" && method === "unstable/set_session_model") {
|
||||
const candidate = params?.modelId as string | undefined;
|
||||
if (candidate) {
|
||||
latestModelId = candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.sender === "client" && method === "session/set_config_option") {
|
||||
const category = params?.category as string | undefined;
|
||||
const optionId = params?.optionId as string | undefined;
|
||||
const seemsModelOption = category === "model" || (typeof optionId === "string" && optionId.toLowerCase().includes("model"));
|
||||
if (!seemsModelOption) continue;
|
||||
const candidate = (params?.value as string | undefined)
|
||||
?? (params?.currentValue as string | undefined)
|
||||
?? (params?.modelId as string | undefined);
|
||||
if (candidate) {
|
||||
latestModelId = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestModelId;
|
||||
}, [events]);
|
||||
|
||||
const modelPillLabel = useMemo(() => {
|
||||
const sessionModelId =
|
||||
currentSessionModelId
|
||||
?? (sessionId ? sessionModelById[sessionId] : undefined)
|
||||
?? (sessionId ? defaultModelByAgent[agentId] : undefined);
|
||||
if (!sessionModelId) return null;
|
||||
return sessionModelId;
|
||||
}, [agentId, currentSessionModelId, defaultModelByAgent, sessionId, sessionModelById]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !currentSessionModelId) return;
|
||||
setSessionModelById((prev) =>
|
||||
prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId }
|
||||
);
|
||||
}, [currentSessionModelId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SESSION_MODELS_KEY, JSON.stringify(sessionModelById));
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}, [sessionModelById]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
|
@ -1041,10 +1416,6 @@ export default function App() {
|
|||
<div className="toast-stack" aria-live="assertive" aria-atomic="false">
|
||||
{errorToasts.map((toast) => (
|
||||
<div key={toast.id} className="toast error" role="status">
|
||||
<div className="toast-content">
|
||||
<div className="toast-title">Request failed</div>
|
||||
<div className="toast-message">{toast.message}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="toast-close"
|
||||
|
|
@ -1053,6 +1424,10 @@ export default function App() {
|
|||
>
|
||||
x
|
||||
</button>
|
||||
<div className="toast-content">
|
||||
<div className="toast-title">Request failed</div>
|
||||
<div className="toast-message">{toast.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1083,6 +1458,7 @@ export default function App() {
|
|||
<header className="header">
|
||||
<div className="header-left">
|
||||
<img src={logoUrl} alt="Sandbox Agent" className="logo-text" style={{ height: '20px', width: 'auto' }} />
|
||||
<span className="header-endpoint">{endpoint}</span>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<a className="header-link" href={docsUrl} target="_blank" rel="noreferrer">
|
||||
|
|
@ -1097,7 +1473,6 @@ export default function App() {
|
|||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
Issues
|
||||
</a>
|
||||
<span className="header-endpoint">{endpoint}</span>
|
||||
<button className="button secondary small" onClick={disconnect}>
|
||||
Disconnect
|
||||
</button>
|
||||
|
|
@ -1147,12 +1522,31 @@ export default function App() {
|
|||
agentsError={agentsError}
|
||||
messagesEndRef={messagesEndRef}
|
||||
agentLabel={agentLabel}
|
||||
modelLabel={modelPillLabel}
|
||||
currentAgentVersion={currentAgent?.version ?? null}
|
||||
sessionEnded={sessionEnded}
|
||||
sessionArchived={sessionArchived}
|
||||
onEndSession={endSession}
|
||||
onArchiveSession={() => {
|
||||
if (sessionId) {
|
||||
void archiveSession(sessionId);
|
||||
}
|
||||
}}
|
||||
onUnarchiveSession={() => {
|
||||
if (sessionId) {
|
||||
void unarchiveSession(sessionId);
|
||||
}
|
||||
}}
|
||||
modesByAgent={modesByAgent}
|
||||
modelsByAgent={modelsByAgent}
|
||||
defaultModelByAgent={defaultModelByAgent}
|
||||
onEventClick={(eventId) => {
|
||||
setDebugTab("events");
|
||||
setHighlightedEventId(eventId);
|
||||
}}
|
||||
isThinking={isThinking}
|
||||
agentId={agentId}
|
||||
tokenUsage={tokenUsage}
|
||||
/>
|
||||
|
||||
<DebugPanel
|
||||
|
|
@ -1160,6 +1554,8 @@ export default function App() {
|
|||
onDebugTabChange={setDebugTab}
|
||||
events={events}
|
||||
onResetEvents={() => setEvents([])}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onClearHighlight={() => setHighlightedEventId(null)}
|
||||
requestLog={requestLog}
|
||||
copiedLogId={copiedLogId}
|
||||
onClearRequestLog={() => setRequestLog([])}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,17 @@ const agentLabels: Record<string, string> = {
|
|||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
opencode: "OpenCode",
|
||||
amp: "Amp"
|
||||
amp: "Amp",
|
||||
pi: "Pi",
|
||||
cursor: "Cursor"
|
||||
};
|
||||
|
||||
const agentLogos: Record<string, string> = {
|
||||
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
|
||||
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
|
||||
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
|
||||
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
|
||||
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
|
||||
};
|
||||
|
||||
const SessionCreateMenu = ({
|
||||
|
|
@ -37,7 +47,7 @@ const SessionCreateMenu = ({
|
|||
modesByAgent: Record<string, AgentModeInfo[]>;
|
||||
modelsByAgent: Record<string, AgentModelInfo[]>;
|
||||
defaultModelByAgent: Record<string, string>;
|
||||
onCreateSession: (agentId: string, config: SessionConfig) => void;
|
||||
onCreateSession: (agentId: string, config: SessionConfig) => Promise<void>;
|
||||
onSelectAgent: (agentId: string) => Promise<void>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -48,7 +58,7 @@ const SessionCreateMenu = ({
|
|||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
const [isCustomModel, setIsCustomModel] = useState(false);
|
||||
const [configLoadDone, setConfigLoadDone] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Reset state when menu closes
|
||||
useEffect(() => {
|
||||
|
|
@ -59,18 +69,10 @@ const SessionCreateMenu = ({
|
|||
setSelectedModel("");
|
||||
setCustomModel("");
|
||||
setIsCustomModel(false);
|
||||
setConfigLoadDone(false);
|
||||
setCreating(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Transition to config phase after load completes — deferred via useEffect
|
||||
// so parent props (modelsByAgent) have settled before we render the config form
|
||||
useEffect(() => {
|
||||
if (phase === "loading-config" && configLoadDone) {
|
||||
setPhase("config");
|
||||
}
|
||||
}, [phase, configLoadDone]);
|
||||
|
||||
// Auto-select first mode when modes load for selected agent
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) return;
|
||||
|
|
@ -80,6 +82,14 @@ const SessionCreateMenu = ({
|
|||
}
|
||||
}, [modesByAgent, selectedAgent, agentMode]);
|
||||
|
||||
// Agent-specific config should not leak between agent selections.
|
||||
useEffect(() => {
|
||||
setAgentMode("");
|
||||
setSelectedModel("");
|
||||
setCustomModel("");
|
||||
setIsCustomModel(false);
|
||||
}, [selectedAgent]);
|
||||
|
||||
// Auto-select default model when agent is selected
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) return;
|
||||
|
|
@ -99,21 +109,21 @@ const SessionCreateMenu = ({
|
|||
|
||||
const handleAgentClick = (agentId: string) => {
|
||||
setSelectedAgent(agentId);
|
||||
setPhase("loading-config");
|
||||
setConfigLoadDone(false);
|
||||
onSelectAgent(agentId).finally(() => {
|
||||
setConfigLoadDone(true);
|
||||
setPhase("config");
|
||||
// Load agent config in background; creation should not block on this call.
|
||||
void onSelectAgent(agentId).catch((error) => {
|
||||
console.error("[SessionCreateMenu] Failed to load agent config:", error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (creating) return;
|
||||
setPhase("agent");
|
||||
setSelectedAgent("");
|
||||
setAgentMode("");
|
||||
setSelectedModel("");
|
||||
setCustomModel("");
|
||||
setIsCustomModel(false);
|
||||
setConfigLoadDone(false);
|
||||
};
|
||||
|
||||
const handleModelSelectChange = (value: string) => {
|
||||
|
|
@ -129,9 +139,17 @@ const SessionCreateMenu = ({
|
|||
|
||||
const resolvedModel = isCustomModel ? customModel : selectedModel;
|
||||
|
||||
const handleCreate = () => {
|
||||
onCreateSession(selectedAgent, { agentMode, model: resolvedModel });
|
||||
onClose();
|
||||
const handleCreate = async () => {
|
||||
if (!selectedAgent) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreateSession(selectedAgent, { agentMode, model: resolvedModel });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("[SessionCreateMenu] Failed to create session:", error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (phase === "agent") {
|
||||
|
|
@ -142,43 +160,57 @@ const SessionCreateMenu = ({
|
|||
{!agentsLoading && !agentsError && agents.length === 0 && (
|
||||
<div className="sidebar-add-status">No agents available.</div>
|
||||
)}
|
||||
{!agentsLoading && !agentsError &&
|
||||
agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className="sidebar-add-option"
|
||||
onClick={() => handleAgentClick(agent.id)}
|
||||
>
|
||||
<div className="agent-option-left">
|
||||
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
|
||||
{agent.version && <span className="agent-option-version">{agent.version}</span>}
|
||||
</div>
|
||||
<div className="agent-option-badges">
|
||||
{agent.installed && <span className="agent-badge installed">Installed</span>}
|
||||
<ArrowRight size={12} className="agent-option-arrow" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!agentsLoading && !agentsError && (() => {
|
||||
const codingAgents = agents.filter((a) => a.id !== "mock");
|
||||
const mockAgent = agents.find((a) => a.id === "mock");
|
||||
return (
|
||||
<>
|
||||
{codingAgents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className="sidebar-add-option"
|
||||
onClick={() => handleAgentClick(agent.id)}
|
||||
>
|
||||
<div className="agent-option-left">
|
||||
{agentLogos[agent.id] && (
|
||||
<img src={agentLogos[agent.id]} alt="" className="agent-option-logo" />
|
||||
)}
|
||||
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
|
||||
{agent.version && <span className="agent-option-version">{agent.version}</span>}
|
||||
</div>
|
||||
<div className="agent-option-badges">
|
||||
{agent.installed && <span className="agent-badge installed">Installed</span>}
|
||||
<ArrowRight size={12} className="agent-option-arrow" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{mockAgent && (
|
||||
<>
|
||||
<div className="agent-divider" />
|
||||
<button
|
||||
className="sidebar-add-option"
|
||||
onClick={() => handleAgentClick(mockAgent.id)}
|
||||
>
|
||||
<div className="agent-option-left">
|
||||
<span className="agent-option-name">{agentLabels[mockAgent.id] ?? mockAgent.id}</span>
|
||||
{mockAgent.version && <span className="agent-option-version">{mockAgent.version}</span>}
|
||||
</div>
|
||||
<div className="agent-option-badges">
|
||||
{mockAgent.installed && <span className="agent-badge installed">Installed</span>}
|
||||
<ArrowRight size={12} className="agent-option-arrow" />
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agentLabel = agentLabels[selectedAgent] ?? selectedAgent;
|
||||
|
||||
if (phase === "loading-config") {
|
||||
return (
|
||||
<div className="session-create-menu">
|
||||
<div className="session-create-header">
|
||||
<button className="session-create-back" onClick={handleBack} title="Back to agents">
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="session-create-agent-name">{agentLabel}</span>
|
||||
</div>
|
||||
<div className="sidebar-add-status">Loading config...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 2: config form
|
||||
const activeModes = modesByAgent[selectedAgent] ?? [];
|
||||
const activeModels = modelsByAgent[selectedAgent] ?? [];
|
||||
|
|
@ -257,8 +289,8 @@ const SessionCreateMenu = ({
|
|||
</div>
|
||||
|
||||
<div className="session-create-actions">
|
||||
<button className="button primary" onClick={handleCreate}>
|
||||
Create Session
|
||||
<button className="button primary" onClick={() => void handleCreate()} disabled={creating}>
|
||||
{creating ? "Creating..." : "Create Session"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { Archive, ArrowLeft, ArrowUpRight, Plus, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AgentInfo } from "sandbox-agent";
|
||||
import { formatShortId } from "../utils/format";
|
||||
|
||||
type AgentModeInfo = { id: string; name: string; description: string };
|
||||
type AgentModelInfo = { id: string; name?: string };
|
||||
|
|
@ -10,6 +11,7 @@ type SessionListItem = {
|
|||
sessionId: string;
|
||||
agent: string;
|
||||
ended: boolean;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
const agentLabels: Record<string, string> = {
|
||||
|
|
@ -42,7 +44,7 @@ const SessionSidebar = ({
|
|||
selectedSessionId: string;
|
||||
onSelectSession: (session: SessionListItem) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateSession: (agentId: string, config: SessionConfig) => void;
|
||||
onCreateSession: (agentId: string, config: SessionConfig) => Promise<void>;
|
||||
onSelectAgent: (agentId: string) => Promise<void>;
|
||||
agents: AgentInfo[];
|
||||
agentsLoading: boolean;
|
||||
|
|
@ -54,7 +56,15 @@ const SessionSidebar = ({
|
|||
defaultModelByAgent: Record<string, string>;
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const archivedCount = sessions.filter((session) => session.archived).length;
|
||||
const activeSessions = sessions.filter((session) => !session.archived);
|
||||
const archivedSessions = sessions.filter((session) => session.archived);
|
||||
const visibleSessions = showArchived ? archivedSessions : activeSessions;
|
||||
const orderedVisibleSessions = showArchived
|
||||
? [...visibleSessions].sort((a, b) => Number(a.ended) - Number(b.ended))
|
||||
: visibleSessions;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMenu) return;
|
||||
|
|
@ -68,13 +78,34 @@ const SessionSidebar = ({
|
|||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent getting stuck in archived view when there are no archived sessions.
|
||||
if (!showArchived) return;
|
||||
if (archivedSessions.length === 0) {
|
||||
setShowArchived(false);
|
||||
}
|
||||
}, [showArchived, archivedSessions.length]);
|
||||
|
||||
return (
|
||||
<div className="session-sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">Sessions</span>
|
||||
<div className="sidebar-header-actions">
|
||||
<button className="sidebar-icon-btn" onClick={onRefresh} title="Refresh sessions">
|
||||
<RefreshCw size={14} />
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
className={`button secondary small ${showArchived ? "active" : ""}`}
|
||||
onClick={() => setShowArchived((value) => !value)}
|
||||
title={showArchived ? "Hide archived sessions" : `Show archived sessions (${archivedCount})`}
|
||||
>
|
||||
{showArchived ? (
|
||||
<ArrowLeft size={12} className="button-icon" />
|
||||
) : (
|
||||
<Archive size={12} className="button-icon" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button className="button secondary small" onClick={onRefresh} title="Refresh sessions">
|
||||
<RefreshCw size={12} className="button-icon" />
|
||||
</button>
|
||||
<div className="sidebar-add-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
|
|
@ -105,30 +136,41 @@ const SessionSidebar = ({
|
|||
<div className="sidebar-empty">Loading sessions...</div>
|
||||
) : sessionsError ? (
|
||||
<div className="sidebar-empty error">{sessionsError}</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="sidebar-empty">No sessions yet.</div>
|
||||
) : visibleSessions.length === 0 ? (
|
||||
<div className="sidebar-empty">{showArchived ? "No archived sessions." : "No sessions yet."}</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""}`}
|
||||
onClick={() => onSelectSession(session)}
|
||||
>
|
||||
<div className="session-item-id">{session.sessionId}</div>
|
||||
<div className="session-item-meta">
|
||||
<span className="session-item-agent">{agentLabels[session.agent] ?? session.agent}</span>
|
||||
{session.ended && <span className="session-item-ended">ended</span>}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
<>
|
||||
{showArchived && <div className="sidebar-empty">Archived Sessions</div>}
|
||||
{orderedVisibleSessions.map((session) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""} ${session.ended ? "ended" : ""} ${session.archived ? "ended" : ""}`}
|
||||
>
|
||||
<button
|
||||
className="session-item-content"
|
||||
onClick={() => onSelectSession(session)}
|
||||
>
|
||||
<div className="session-item-id" title={session.sessionId}>
|
||||
{formatShortId(session.sessionId)}
|
||||
</div>
|
||||
<div className="session-item-meta">
|
||||
<span className="session-item-agent">
|
||||
{agentLabels[session.agent] ?? session.agent}
|
||||
</span>
|
||||
{(session.archived || session.ended) && <span className="session-item-ended">ended</span>}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-persistence-note">
|
||||
Sessions are persisted in your browser using IndexedDB.{" "}
|
||||
<a href={persistenceDocsUrl} target="_blank" rel="noreferrer">
|
||||
<a href={persistenceDocsUrl} target="_blank" rel="noreferrer" style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
|
||||
Configure persistence
|
||||
<ArrowUpRight size={10} />
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,150 +1,251 @@
|
|||
import { useState } from "react";
|
||||
import { getAvatarLabel, getMessageClass } from "./messageUtils";
|
||||
import { getMessageClass } from "./messageUtils";
|
||||
import type { TimelineEntry } from "./types";
|
||||
import { AlertTriangle, Settings, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react";
|
||||
|
||||
const CollapsibleMessage = ({
|
||||
id,
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
className = ""
|
||||
const ToolItem = ({
|
||||
entry,
|
||||
isLast,
|
||||
onEventClick
|
||||
}: {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
entry: TimelineEntry;
|
||||
isLast: boolean;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isTool = entry.kind === "tool";
|
||||
const isReasoning = entry.kind === "reasoning";
|
||||
const isMeta = entry.kind === "meta";
|
||||
|
||||
const isComplete = isTool && (entry.toolStatus === "completed" || entry.toolStatus === "failed");
|
||||
const isFailed = isTool && entry.toolStatus === "failed";
|
||||
const isInProgress = isTool && entry.toolStatus === "in_progress";
|
||||
|
||||
let label = "";
|
||||
let icon = <Info size={12} />;
|
||||
|
||||
if (isTool) {
|
||||
const statusLabel = entry.toolStatus && entry.toolStatus !== "completed"
|
||||
? ` (${entry.toolStatus.replace("_", " ")})`
|
||||
: "";
|
||||
label = `${entry.toolName ?? "tool"}${statusLabel}`;
|
||||
icon = <Wrench size={12} />;
|
||||
} else if (isReasoning) {
|
||||
label = `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`;
|
||||
icon = <Brain size={12} />;
|
||||
} else if (isMeta) {
|
||||
label = entry.meta?.title ?? "Status";
|
||||
icon = entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
|
||||
}
|
||||
|
||||
const hasContent = isTool
|
||||
? Boolean(entry.toolInput || entry.toolOutput)
|
||||
: isReasoning
|
||||
? Boolean(entry.reasoning?.text?.trim())
|
||||
: Boolean(entry.meta?.detail?.trim());
|
||||
|
||||
return (
|
||||
<div className={`collapsible-message ${className}`}>
|
||||
<button className="collapsible-header" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{expanded && <div className="collapsible-content">{children}</div>}
|
||||
<div className={`tool-item ${isLast ? "last" : ""} ${isFailed ? "failed" : ""}`}>
|
||||
<div className="tool-item-connector">
|
||||
<div className="tool-item-dot" />
|
||||
{!isLast && <div className="tool-item-line" />}
|
||||
</div>
|
||||
<div className="tool-item-content">
|
||||
<button
|
||||
className={`tool-item-header ${expanded ? "expanded" : ""}`}
|
||||
onClick={() => hasContent && setExpanded(!expanded)}
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<span className="tool-item-icon">{icon}</span>
|
||||
<span className="tool-item-label">{label}</span>
|
||||
{isInProgress && (
|
||||
<span className="tool-item-spinner">
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
</span>
|
||||
)}
|
||||
{entry.eventId && onEventClick && (
|
||||
<span
|
||||
className="tool-item-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(entry.eventId!);
|
||||
}}
|
||||
title="View in Events"
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</span>
|
||||
)}
|
||||
{hasContent && (
|
||||
<span className="tool-item-chevron">
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && hasContent && (
|
||||
<div className="tool-item-body">
|
||||
{isTool && entry.toolInput && (
|
||||
<div className="tool-section">
|
||||
<div className="tool-section-title">Input</div>
|
||||
<pre className="tool-code">{entry.toolInput}</pre>
|
||||
</div>
|
||||
)}
|
||||
{isTool && isComplete && entry.toolOutput && (
|
||||
<div className="tool-section">
|
||||
<div className="tool-section-title">Output</div>
|
||||
<pre className="tool-code">{entry.toolOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
{isReasoning && entry.reasoning?.text && (
|
||||
<div className="tool-section">
|
||||
<pre className="tool-code muted">{entry.reasoning.text}</pre>
|
||||
</div>
|
||||
)}
|
||||
{isMeta && entry.meta?.detail && (
|
||||
<div className="tool-section">
|
||||
<pre className="tool-code">{entry.meta.detail}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEventClick?: (eventId: string) => void }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// If only one item, render it directly without macro wrapper
|
||||
if (entries.length === 1) {
|
||||
return (
|
||||
<div className="tool-group-single">
|
||||
<ToolItem entry={entries[0]} isLast={true} onEventClick={onEventClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = entries.length;
|
||||
const summary = `${totalCount} Event${totalCount > 1 ? "s" : ""}`;
|
||||
|
||||
// Check if any are in progress
|
||||
const hasInProgress = entries.some(e => e.kind === "tool" && e.toolStatus === "in_progress");
|
||||
const hasFailed = entries.some(e => e.kind === "tool" && e.toolStatus === "failed");
|
||||
|
||||
return (
|
||||
<div className={`tool-group-container ${hasFailed ? "failed" : ""}`}>
|
||||
<button
|
||||
className={`tool-group-header ${expanded ? "expanded" : ""}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="tool-group-icon">
|
||||
<PlayCircle size={14} />
|
||||
</span>
|
||||
<span className="tool-group-label">{summary}</span>
|
||||
<span className="tool-group-chevron">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="tool-group">
|
||||
{entries.map((entry, idx) => (
|
||||
<ToolItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={idx === entries.length - 1}
|
||||
onEventClick={onEventClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const agentLogos: Record<string, string> = {
|
||||
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
|
||||
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
|
||||
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
|
||||
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
|
||||
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
|
||||
};
|
||||
|
||||
const ChatMessages = ({
|
||||
entries,
|
||||
sessionError,
|
||||
eventError,
|
||||
messagesEndRef
|
||||
messagesEndRef,
|
||||
onEventClick,
|
||||
isThinking,
|
||||
agentId
|
||||
}: {
|
||||
entries: TimelineEntry[];
|
||||
sessionError: string | null;
|
||||
eventError: string | null;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
}) => {
|
||||
// Group consecutive tool/reasoning/meta entries together
|
||||
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider"; entries: TimelineEntry[] }> = [];
|
||||
|
||||
let currentToolGroup: TimelineEntry[] = [];
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (currentToolGroup.length > 0) {
|
||||
groupedEntries.push({ type: "tool-group", entries: currentToolGroup });
|
||||
currentToolGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
const isStatusDivider = entry.kind === "meta" &&
|
||||
["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? "");
|
||||
|
||||
if (isStatusDivider) {
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "divider", entries: [entry] });
|
||||
} else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) {
|
||||
currentToolGroup.push(entry);
|
||||
} else if (entry.kind === "meta" && !entry.meta?.detail) {
|
||||
// Simple meta without detail - add to tool group as single item
|
||||
currentToolGroup.push(entry);
|
||||
} else {
|
||||
// Regular message
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "message", entries: [entry] });
|
||||
}
|
||||
}
|
||||
flushToolGroup();
|
||||
|
||||
return (
|
||||
<div className="messages">
|
||||
{entries.map((entry) => {
|
||||
if (entry.kind === "meta") {
|
||||
const isError = entry.meta?.severity === "error";
|
||||
{groupedEntries.map((group, idx) => {
|
||||
if (group.type === "divider") {
|
||||
const entry = group.entries[0];
|
||||
const title = entry.meta?.title ?? "Status";
|
||||
const isStatusDivider = ["Session Started", "Turn Started", "Turn Ended"].includes(title);
|
||||
|
||||
if (isStatusDivider) {
|
||||
return (
|
||||
<div key={entry.id} className="status-divider">
|
||||
<div className="status-divider-line" />
|
||||
<span className="status-divider-text">
|
||||
<Settings size={12} />
|
||||
{title}
|
||||
</span>
|
||||
<div className="status-divider-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Other status messages - collapsible only if there's detail
|
||||
const hasDetail = Boolean(entry.meta?.detail);
|
||||
if (hasDetail) {
|
||||
return (
|
||||
<CollapsibleMessage
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
icon={isError ? <AlertTriangle size={14} className="error-icon" /> : <Settings size={14} className="system-icon" />}
|
||||
label={title}
|
||||
className={isError ? "error" : "system"}
|
||||
>
|
||||
<div className="part-body">{entry.meta?.detail}</div>
|
||||
</CollapsibleMessage>
|
||||
);
|
||||
}
|
||||
|
||||
// No detail - simple non-collapsible message
|
||||
return (
|
||||
<div key={entry.id} className={`simple-status-message ${isError ? "error" : "system"}`}>
|
||||
{isError ? <AlertTriangle size={14} className="error-icon" /> : <Settings size={14} className="system-icon" />}
|
||||
<span>{title}</span>
|
||||
<div key={entry.id} className="status-divider">
|
||||
<div className="status-divider-line" />
|
||||
<span className="status-divider-text">{title}</span>
|
||||
<div className="status-divider-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "reasoning") {
|
||||
return (
|
||||
<CollapsibleMessage
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
icon={<Settings size={14} className="system-icon" />}
|
||||
label={`Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`}
|
||||
className="system"
|
||||
>
|
||||
<div className="part-body muted">{entry.reasoning?.text}</div>
|
||||
</CollapsibleMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "tool") {
|
||||
const isComplete = entry.toolStatus === "completed" || entry.toolStatus === "failed";
|
||||
const isFailed = entry.toolStatus === "failed";
|
||||
const statusLabel = entry.toolStatus && entry.toolStatus !== "completed"
|
||||
? entry.toolStatus.replace("_", " ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<CollapsibleMessage
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
icon={<span className="tool-icon">T</span>}
|
||||
label={`${entry.toolName ?? "tool"}${statusLabel ? ` (${statusLabel})` : ""}`}
|
||||
className={`tool${isFailed ? " error" : ""}`}
|
||||
>
|
||||
{entry.toolInput && (
|
||||
<div className="part">
|
||||
<div className="part-title">input</div>
|
||||
<pre className="code-block">{entry.toolInput}</pre>
|
||||
</div>
|
||||
)}
|
||||
{isComplete && entry.toolOutput && (
|
||||
<div className="part">
|
||||
<div className="part-title">output</div>
|
||||
<pre className="code-block">{entry.toolOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
{!isComplete && !entry.toolInput && (
|
||||
<span className="thinking-indicator">
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
</span>
|
||||
)}
|
||||
</CollapsibleMessage>
|
||||
);
|
||||
if (group.type === "tool-group") {
|
||||
return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />;
|
||||
}
|
||||
|
||||
// Regular message
|
||||
const entry = group.entries[0];
|
||||
const messageClass = getMessageClass(entry);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={`message ${messageClass}`}>
|
||||
<div className="avatar">{getAvatarLabel(messageClass)}</div>
|
||||
<div key={entry.id} className={`message ${messageClass} no-avatar`}>
|
||||
<div className="message-content">
|
||||
{entry.text ? (
|
||||
<div className="part-body">{entry.text}</div>
|
||||
|
|
@ -161,6 +262,22 @@ const ChatMessages = ({
|
|||
})}
|
||||
{sessionError && <div className="message-error">{sessionError}</div>}
|
||||
{eventError && <div className="message-error">{eventError}</div>}
|
||||
{isThinking && (
|
||||
<div className="thinking-row">
|
||||
<div className="thinking-avatar">
|
||||
{agentId && agentLogos[agentId] ? (
|
||||
<img src={agentLogos[agentId]} alt="" className="thinking-avatar-img" />
|
||||
) : (
|
||||
<span className="ai-label">AI</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="thinking-indicator">
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
|
||||
import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AgentInfo } from "sandbox-agent";
|
||||
import { formatShortId } from "../../utils/format";
|
||||
|
||||
type AgentModeInfo = { id: string; name: string; description: string };
|
||||
type AgentModelInfo = { id: string; name?: string };
|
||||
|
|
@ -24,12 +25,20 @@ const ChatPanel = ({
|
|||
agentsError,
|
||||
messagesEndRef,
|
||||
agentLabel,
|
||||
modelLabel,
|
||||
currentAgentVersion,
|
||||
sessionEnded,
|
||||
sessionArchived,
|
||||
onEndSession,
|
||||
onArchiveSession,
|
||||
onUnarchiveSession,
|
||||
modesByAgent,
|
||||
modelsByAgent,
|
||||
defaultModelByAgent,
|
||||
onEventClick,
|
||||
isThinking,
|
||||
agentId,
|
||||
tokenUsage,
|
||||
}: {
|
||||
sessionId: string;
|
||||
transcriptEntries: TimelineEntry[];
|
||||
|
|
@ -38,21 +47,30 @@ const ChatPanel = ({
|
|||
onMessageChange: (value: string) => void;
|
||||
onSendMessage: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCreateSession: (agentId: string, config: SessionConfig) => void;
|
||||
onCreateSession: (agentId: string, config: SessionConfig) => Promise<void>;
|
||||
onSelectAgent: (agentId: string) => Promise<void>;
|
||||
agents: AgentInfo[];
|
||||
agentsLoading: boolean;
|
||||
agentsError: string | null;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
agentLabel: string;
|
||||
modelLabel?: string | null;
|
||||
currentAgentVersion?: string | null;
|
||||
sessionEnded: boolean;
|
||||
sessionArchived: boolean;
|
||||
onEndSession: () => void;
|
||||
onArchiveSession: () => void;
|
||||
onUnarchiveSession: () => void;
|
||||
modesByAgent: Record<string, AgentModeInfo[]>;
|
||||
modelsByAgent: Record<string, AgentModelInfo[]>;
|
||||
defaultModelByAgent: Record<string, string>;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
tokenUsage?: { used: number; size: number; cost?: number } | null;
|
||||
}) => {
|
||||
const [showAgentMenu, setShowAgentMenu] = useState(false);
|
||||
const [copiedSessionId, setCopiedSessionId] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -67,21 +85,92 @@ const ChatPanel = ({
|
|||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showAgentMenu]);
|
||||
|
||||
const copySessionId = async () => {
|
||||
if (!sessionId) return;
|
||||
const onSuccess = () => {
|
||||
setCopiedSessionId(true);
|
||||
window.setTimeout(() => setCopiedSessionId(false), 1200);
|
||||
};
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(sessionId);
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fallback below for older/insecure contexts.
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = sessionId;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
onSuccess();
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveSession = () => {
|
||||
if (!sessionId) return;
|
||||
onArchiveSession();
|
||||
};
|
||||
|
||||
const handleUnarchiveSession = () => {
|
||||
if (!sessionId) return;
|
||||
onUnarchiveSession();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-header-left">
|
||||
<MessageSquare className="button-icon" />
|
||||
<span className="panel-title">{sessionId ? "Session" : "No Session"}</span>
|
||||
{sessionId && <span className="session-id-display">{sessionId}</span>}
|
||||
<span className="panel-title">{sessionId ? agentLabel : "No Session"}</span>
|
||||
{sessionId && modelLabel && (
|
||||
<span className="header-meta-pill" title={modelLabel}>
|
||||
{modelLabel}
|
||||
</span>
|
||||
)}
|
||||
{sessionId && currentAgentVersion && (
|
||||
<span className="header-meta-pill">v{currentAgentVersion}</span>
|
||||
)}
|
||||
{sessionId && (
|
||||
<button
|
||||
type="button"
|
||||
className="session-id-display"
|
||||
title={copiedSessionId ? "Copied" : `${sessionId} (click to copy)`}
|
||||
onClick={() => void copySessionId()}
|
||||
>
|
||||
{copiedSessionId ? "Copied" : formatShortId(sessionId)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="panel-header-right">
|
||||
{sessionId && tokenUsage && (
|
||||
<span className="token-pill">{tokenUsage.used.toLocaleString()} tokens</span>
|
||||
)}
|
||||
{sessionId && (
|
||||
sessionEnded ? (
|
||||
<span className="button ghost small" style={{ opacity: 0.5, cursor: "default" }} title="Session ended">
|
||||
<CheckSquare size={12} />
|
||||
Ended
|
||||
</span>
|
||||
<>
|
||||
<span className="button ghost small session-ended-status" title="Session ended">
|
||||
<CheckSquare size={12} />
|
||||
Ended
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost small"
|
||||
onClick={sessionArchived ? handleUnarchiveSession : handleArchiveSession}
|
||||
title={sessionArchived ? "Unarchive session" : "Archive session"}
|
||||
>
|
||||
<Archive size={12} />
|
||||
{sessionArchived ? "Unarchive" : "Archive"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -97,12 +186,18 @@ const ChatPanel = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{sessionError && (
|
||||
<div className="error-banner">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{sessionError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="messages-container">
|
||||
{!sessionId ? (
|
||||
<div className="empty-state">
|
||||
<MessageSquare className="empty-state-icon" />
|
||||
<div className="empty-state-title">No Session Selected</div>
|
||||
<p className="empty-state-text">Create a new session to start chatting with an agent.</p>
|
||||
<p className="empty-state-text no-session-subtext">Create a new session to start chatting with an agent.</p>
|
||||
<div className="empty-state-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
className="button primary"
|
||||
|
|
@ -135,7 +230,11 @@ const ChatPanel = ({
|
|||
<ChatMessages
|
||||
entries={transcriptEntries}
|
||||
sessionError={sessionError}
|
||||
eventError={null}
|
||||
messagesEndRef={messagesEndRef}
|
||||
onEventClick={onEventClick}
|
||||
isThinking={isThinking}
|
||||
agentId={agentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -145,24 +244,9 @@ const ChatPanel = ({
|
|||
onMessageChange={onMessageChange}
|
||||
onSendMessage={onSendMessage}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
|
||||
disabled={!sessionId}
|
||||
placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"}
|
||||
disabled={!sessionId || sessionEnded}
|
||||
/>
|
||||
|
||||
{sessionId && (
|
||||
<div className="session-config-bar">
|
||||
<div className="session-config-field">
|
||||
<span className="session-config-label">Agent</span>
|
||||
<span className="session-config-value">{agentLabel}</span>
|
||||
</div>
|
||||
{currentAgentVersion && (
|
||||
<div className="session-config-field">
|
||||
<span className="session-config-label">Version</span>
|
||||
<span className="session-config-value">{currentAgentVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { TimelineEntry } from "./types";
|
||||
import { Settings, AlertTriangle, User } from "lucide-react";
|
||||
import { Settings, AlertTriangle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const getMessageClass = (entry: TimelineEntry) => {
|
||||
|
|
@ -11,7 +11,7 @@ export const getMessageClass = (entry: TimelineEntry) => {
|
|||
};
|
||||
|
||||
export const getAvatarLabel = (messageClass: string): ReactNode => {
|
||||
if (messageClass === "user") return <User size={14} />;
|
||||
if (messageClass === "user") return null;
|
||||
if (messageClass === "tool") return "T";
|
||||
if (messageClass === "system") return <Settings size={14} />;
|
||||
if (messageClass === "error") return <AlertTriangle size={14} />;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export type TimelineEntry = {
|
||||
id: string;
|
||||
eventId?: string; // Links back to the original event for navigation
|
||||
kind: "message" | "tool" | "meta" | "reasoning";
|
||||
time: string;
|
||||
// For messages:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ const DebugPanel = ({
|
|||
onDebugTabChange,
|
||||
events,
|
||||
onResetEvents,
|
||||
highlightedEventId,
|
||||
onClearHighlight,
|
||||
requestLog,
|
||||
copiedLogId,
|
||||
onClearRequestLog,
|
||||
|
|
@ -33,6 +35,8 @@ const DebugPanel = ({
|
|||
onDebugTabChange: (tab: DebugTab) => void;
|
||||
events: SessionEvent[];
|
||||
onResetEvents: () => void;
|
||||
highlightedEventId?: string | null;
|
||||
onClearHighlight?: () => void;
|
||||
requestLog: RequestLog[];
|
||||
copiedLogId: number | null;
|
||||
onClearRequestLog: () => void;
|
||||
|
|
@ -86,6 +90,8 @@ const DebugPanel = ({
|
|||
<EventsTab
|
||||
events={events}
|
||||
onClear={onResetEvents}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onClearHighlight={onClearHighlight}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ import {
|
|||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { SessionEvent } from "sandbox-agent";
|
||||
import { formatJson, formatTime } from "../../utils/format";
|
||||
import { formatJson, formatShortId, formatTime } from "../../utils/format";
|
||||
|
||||
type EventIconInfo = { Icon: LucideIcon; category: string };
|
||||
|
||||
|
|
@ -111,9 +111,13 @@ function getEventIcon(method: string, payload: Record<string, unknown>): EventIc
|
|||
const EventsTab = ({
|
||||
events,
|
||||
onClear,
|
||||
highlightedEventId,
|
||||
onClearHighlight,
|
||||
}: {
|
||||
events: SessionEvent[];
|
||||
onClear: () => void;
|
||||
highlightedEventId?: string | null;
|
||||
onClearHighlight?: () => void;
|
||||
}) => {
|
||||
const [collapsedEvents, setCollapsedEvents] = useState<Record<string, boolean>>({});
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -155,6 +159,25 @@ const EventsTab = ({
|
|||
}
|
||||
}, [events.length]);
|
||||
|
||||
// Scroll to highlighted event (with delay to ensure DOM is ready after tab switch)
|
||||
useEffect(() => {
|
||||
if (highlightedEventId) {
|
||||
const scrollToEvent = () => {
|
||||
const el = document.getElementById(`event-${highlightedEventId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Clear highlight after animation
|
||||
setTimeout(() => {
|
||||
onClearHighlight?.();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
// Small delay to ensure tab switch and DOM render completes
|
||||
const timer = setTimeout(scrollToEvent, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [highlightedEventId, onClearHighlight]);
|
||||
|
||||
const getMethod = (event: SessionEvent): string => {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
return typeof payload.method === "string" ? payload.method : "(response)";
|
||||
|
|
@ -200,8 +223,14 @@ const EventsTab = ({
|
|||
const time = formatTime(new Date(event.createdAt).toISOString());
|
||||
const senderClass = event.sender === "client" ? "client" : "agent";
|
||||
|
||||
const isHighlighted = highlightedEventId === event.id;
|
||||
|
||||
return (
|
||||
<div key={eventKey} className={`event-item ${isCollapsed ? "collapsed" : "expanded"}`}>
|
||||
<div
|
||||
key={eventKey}
|
||||
id={`event-${event.id}`}
|
||||
className={`event-item ${isCollapsed ? "collapsed" : "expanded"} ${isHighlighted ? "highlighted" : ""}`}
|
||||
>
|
||||
<button
|
||||
className="event-summary"
|
||||
type="button"
|
||||
|
|
@ -219,8 +248,8 @@ const EventsTab = ({
|
|||
</span>
|
||||
<span className="event-time">{time}</span>
|
||||
</div>
|
||||
<div className="event-id">
|
||||
{event.id}
|
||||
<div className="event-id" title={event.id}>
|
||||
{formatShortId(event.id)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="event-chevron">
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ const McpTab = ({
|
|||
value={editJson}
|
||||
onChange={(e) => { setEditJson(e.target.value); setEditError(null); }}
|
||||
rows={6}
|
||||
style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11 }}
|
||||
style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11, resize: "vertical" }}
|
||||
/>
|
||||
{editError && <div className="banner error" style={{ marginTop: 4 }}>{editError}</div>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SandboxAgent } from "sandbox-agent";
|
||||
import { formatJson } from "../../utils/format";
|
||||
|
||||
|
|
@ -13,10 +13,47 @@ const SkillsTab = ({
|
|||
}: {
|
||||
getClient: () => SandboxAgent;
|
||||
}) => {
|
||||
const officialSkills = [
|
||||
{
|
||||
name: "Sandbox Agent SDK",
|
||||
skillId: "sandbox-agent",
|
||||
source: "rivet-dev/skills",
|
||||
summary: "Skills bundle for fast Sandbox Agent SDK setup and consistent workflows.",
|
||||
},
|
||||
{
|
||||
name: "Rivet",
|
||||
skillId: "rivet",
|
||||
source: "rivet-dev/skills",
|
||||
summary: "Open-source platform for building, deploying, and scaling AI agents.",
|
||||
features: [
|
||||
"Session Persistence",
|
||||
"Resumable Sessions",
|
||||
"Multi-Agent Support",
|
||||
"Realtime Events",
|
||||
"Tool Call Visibility",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const [directory, setDirectory] = useState("/");
|
||||
const [entries, setEntries] = useState<SkillEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [showSdkSkills, setShowSdkSkills] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSdkSkills) return;
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (!dropdownRef.current) return;
|
||||
if (!dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowSdkSkills(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showSdkSkills]);
|
||||
|
||||
// Add form state
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
|
@ -128,11 +165,118 @@ const SkillsTab = ({
|
|||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
const copyText = async (id: string, text: string) => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
setCopiedId(id);
|
||||
window.setTimeout(() => {
|
||||
setCopiedId((current) => (current === id ? null : current));
|
||||
}, 1800);
|
||||
} catch {
|
||||
setError("Failed to copy snippet");
|
||||
}
|
||||
};
|
||||
|
||||
const applySkillPreset = (skill: typeof officialSkills[0]) => {
|
||||
setEditing(true);
|
||||
setEditName(skill.skillId);
|
||||
setEditSource(skill.source);
|
||||
setEditType("github");
|
||||
setEditRef("");
|
||||
setEditSubpath("");
|
||||
setEditSkills(skill.skillId);
|
||||
setEditError(null);
|
||||
setShowSdkSkills(false);
|
||||
};
|
||||
|
||||
const copySkillToInput = async (skillId: string) => {
|
||||
const skill = officialSkills.find((s) => s.skillId === skillId);
|
||||
if (skill) {
|
||||
applySkillPreset(skill);
|
||||
await copyText(`skill-input-${skillId}`, skillId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
|
||||
<span className="card-meta">Skills Configuration</span>
|
||||
<div className="inline-row">
|
||||
<div className="inline-row" style={{ gap: 6 }}>
|
||||
<div style={{ position: "relative" }} ref={dropdownRef}>
|
||||
<button
|
||||
className="button secondary small"
|
||||
onClick={() => setShowSdkSkills((prev) => !prev)}
|
||||
title="Toggle official skills list"
|
||||
>
|
||||
{showSdkSkills ? <ChevronDown className="button-icon" style={{ width: 12, height: 12 }} /> : <ChevronRight className="button-icon" style={{ width: 12, height: 12 }} />}
|
||||
Official Skills
|
||||
</button>
|
||||
{showSdkSkills && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
right: 0,
|
||||
marginTop: 4,
|
||||
width: 320,
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
zIndex: 100,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
<div className="card-meta" style={{ marginBottom: 8 }}>
|
||||
Pick a skill to auto-fill the form.
|
||||
</div>
|
||||
{officialSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.name}
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
padding: "8px 10px",
|
||||
background: "var(--surface-2)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<div className="inline-row" style={{ justifyContent: "space-between", gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 12 }}>{skill.name}</div>
|
||||
<button className="button ghost small" onClick={() => void copySkillToInput(skill.skillId)}>
|
||||
{copiedId === `skill-input-${skill.skillId}` ? "Filled" : "Use"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-meta" style={{ fontSize: 10, marginBottom: skill.features ? 6 : 0 }}>{skill.summary}</div>
|
||||
{skill.features && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{skill.features.map((feature) => (
|
||||
<span key={feature} className="pill accent" style={{ fontSize: 9 }}>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!editing && (
|
||||
<button className="button secondary small" onClick={startAdd}>
|
||||
<Plus className="button-icon" style={{ width: 12, height: 12 }} />
|
||||
|
|
|
|||
|
|
@ -16,3 +16,9 @@ export const formatTime = (value: string) => {
|
|||
};
|
||||
|
||||
export const escapeSingleQuotes = (value: string) => value.replace(/'/g, `'\\''`);
|
||||
|
||||
export const formatShortId = (value: string, head = 8, tail = 4) => {
|
||||
if (!value) return "";
|
||||
if (value.length <= head + tail + 1) return value;
|
||||
return `${value.slice(0, head)}...${value.slice(-tail)}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -271,6 +271,8 @@ class StreamableHttpAcpTransport {
|
|||
private closed = false;
|
||||
private closingPromise: Promise<void> | null = null;
|
||||
private postedOnce = false;
|
||||
private readonly seenResponseIds = new Set<string>();
|
||||
private readonly seenResponseIdOrder: string[] = [];
|
||||
|
||||
constructor(options: StreamableHttpAcpTransportOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
||||
|
|
@ -535,6 +537,21 @@ class StreamableHttpAcpTransport {
|
|||
return;
|
||||
}
|
||||
|
||||
const responseId = responseEnvelopeId(envelope);
|
||||
if (responseId) {
|
||||
if (this.seenResponseIds.has(responseId)) {
|
||||
return;
|
||||
}
|
||||
this.seenResponseIds.add(responseId);
|
||||
this.seenResponseIdOrder.push(responseId);
|
||||
if (this.seenResponseIdOrder.length > 2048) {
|
||||
const oldest = this.seenResponseIdOrder.shift();
|
||||
if (oldest) {
|
||||
this.seenResponseIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.observeEnvelope(envelope, "inbound");
|
||||
|
||||
try {
|
||||
|
|
@ -632,10 +649,32 @@ function buildClientHandlers(client?: Partial<Client>): Client {
|
|||
waitForTerminalExit: client?.waitForTerminalExit,
|
||||
killTerminal: client?.killTerminal,
|
||||
extMethod: client?.extMethod,
|
||||
extNotification: client?.extNotification,
|
||||
extNotification: async (method: string, params: Record<string, unknown>) => {
|
||||
if (client?.extNotification) {
|
||||
await client.extNotification(method, params);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function responseEnvelopeId(message: AnyMessage): string | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
if ("method" in record) {
|
||||
return null;
|
||||
}
|
||||
if (!("result" in record) && !("error" in record)) {
|
||||
return null;
|
||||
}
|
||||
const id = record.id;
|
||||
if (id === null || id === undefined) {
|
||||
return null;
|
||||
}
|
||||
return String(id);
|
||||
}
|
||||
|
||||
async function readProblem(response: Response): Promise<ProblemDetails | undefined> {
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
|
|
|
|||
|
|
@ -175,6 +175,8 @@ export class LiveAcpConnection {
|
|||
private readonly pendingNewSessionLocals: string[] = [];
|
||||
private readonly pendingRequestSessionById = new Map<string, string>();
|
||||
private readonly pendingReplayByLocalSessionId = new Map<string, string>();
|
||||
private lastAdapterExit: { success: boolean; code: number | null } | null = null;
|
||||
private lastAdapterExitAt = 0;
|
||||
|
||||
private readonly onObservedEnvelope: (
|
||||
connection: LiveAcpConnection,
|
||||
|
|
@ -230,6 +232,10 @@ export class LiveAcpConnection {
|
|||
sessionUpdate: async (_notification: SessionNotification) => {
|
||||
// Session updates are observed via envelope persistence.
|
||||
},
|
||||
extNotification: async (method: string, params: Record<string, unknown>) => {
|
||||
if (!live) return;
|
||||
live.handleAdapterNotification(method, params);
|
||||
},
|
||||
},
|
||||
onEnvelope: (envelope, direction) => {
|
||||
if (!live) {
|
||||
|
|
@ -286,6 +292,7 @@ export class LiveAcpConnection {
|
|||
localSessionId: string,
|
||||
sessionInit: Omit<NewSessionRequest, "_meta">,
|
||||
): Promise<NewSessionResponse> {
|
||||
const createStartedAt = Date.now();
|
||||
this.pendingNewSessionLocals.push(localSessionId);
|
||||
|
||||
try {
|
||||
|
|
@ -297,6 +304,11 @@ export class LiveAcpConnection {
|
|||
if (index !== -1) {
|
||||
this.pendingNewSessionLocals.splice(index, 1);
|
||||
}
|
||||
const adapterExit = this.lastAdapterExit;
|
||||
if (adapterExit && this.lastAdapterExitAt >= createStartedAt) {
|
||||
const suffix = adapterExit.code == null ? "" : ` (code ${adapterExit.code})`;
|
||||
throw new Error(`Agent process exited while creating session${suffix}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -356,6 +368,17 @@ export class LiveAcpConnection {
|
|||
this.onObservedEnvelope(this, envelope, direction, localSessionId);
|
||||
}
|
||||
|
||||
private handleAdapterNotification(method: string, params: Record<string, unknown>): void {
|
||||
if (method !== "_adapter/agent_exited") {
|
||||
return;
|
||||
}
|
||||
this.lastAdapterExit = {
|
||||
success: params.success === true,
|
||||
code: typeof params.code === "number" ? params.code : null,
|
||||
};
|
||||
this.lastAdapterExitAt = Date.now();
|
||||
}
|
||||
|
||||
private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null {
|
||||
const id = envelopeId(envelope);
|
||||
const method = envelopeMethod(envelope);
|
||||
|
|
@ -413,6 +436,7 @@ export class SandboxAgent {
|
|||
private spawnHandle?: SandboxAgentSpawnHandle;
|
||||
|
||||
private readonly liveConnections = new Map<string, LiveAcpConnection>();
|
||||
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
|
||||
private readonly sessionHandles = new Map<string, Session>();
|
||||
private readonly eventListeners = new Map<string, Set<SessionEventListener>>();
|
||||
private readonly nextSessionEventIndexBySession = new Map<string, number>();
|
||||
|
|
@ -463,6 +487,15 @@ export class SandboxAgent {
|
|||
async dispose(): Promise<void> {
|
||||
const connections = [...this.liveConnections.values()];
|
||||
this.liveConnections.clear();
|
||||
const pending = [...this.pendingLiveConnections.values()];
|
||||
this.pendingLiveConnections.clear();
|
||||
|
||||
const pendingSettled = await Promise.allSettled(pending);
|
||||
for (const item of pendingSettled) {
|
||||
if (item.status === "fulfilled") {
|
||||
connections.push(item.value);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
connections.map(async (connection) => {
|
||||
|
|
@ -725,21 +758,43 @@ export class SandboxAgent {
|
|||
return existing;
|
||||
}
|
||||
|
||||
const serverId = `sdk-${agent}-${randomId()}`;
|
||||
const created = await LiveAcpConnection.create({
|
||||
baseUrl: this.baseUrl,
|
||||
token: this.token,
|
||||
fetcher: this.fetcher,
|
||||
headers: this.defaultHeaders,
|
||||
agent,
|
||||
serverId,
|
||||
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
|
||||
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
|
||||
},
|
||||
});
|
||||
const pending = this.pendingLiveConnections.get(agent);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
this.liveConnections.set(agent, created);
|
||||
return created;
|
||||
const creating = (async () => {
|
||||
const serverId = `sdk-${agent}-${randomId()}`;
|
||||
const created = await LiveAcpConnection.create({
|
||||
baseUrl: this.baseUrl,
|
||||
token: this.token,
|
||||
fetcher: this.fetcher,
|
||||
headers: this.defaultHeaders,
|
||||
agent,
|
||||
serverId,
|
||||
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
|
||||
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
|
||||
},
|
||||
});
|
||||
|
||||
const raced = this.liveConnections.get(agent);
|
||||
if (raced) {
|
||||
await created.close();
|
||||
return raced;
|
||||
}
|
||||
|
||||
this.liveConnections.set(agent, created);
|
||||
return created;
|
||||
})();
|
||||
|
||||
this.pendingLiveConnections.set(agent, creating);
|
||||
try {
|
||||
return await creating;
|
||||
} finally {
|
||||
if (this.pendingLiveConnections.get(agent) === creating) {
|
||||
this.pendingLiveConnections.delete(agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async persistObservedEnvelope(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue