Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export interface ImageAttachment {

export interface NotificationParams {
sessionId: string;
sessionAlias?: string;
context?: string;
feedbackUrl: string;
}

export interface FeedbackCallback {
Expand All @@ -23,8 +23,8 @@ export interface FeedbackCallback {

export interface FYIParams {
sessionId: string;
sessionAlias?: string;
context: string;
feedbackUrl: string;
}

export interface NotificationChannel {
Expand Down Expand Up @@ -307,7 +307,7 @@ export class TelegramChannel implements NotificationChannel {
}

const messageParts = this.formatNotificationParts(params);
const keyboard = this.buildKeyboard(params.sessionId, params.feedbackUrl);
const keyboard = this.buildKeyboard(params.sessionId);

for (const chatId of this.registeredChatIds) {
try {
Expand Down Expand Up @@ -408,17 +408,19 @@ export class TelegramChannel implements NotificationChannel {

private formatNotificationParts(params: NotificationParams): string[] {
const TELEGRAM_MAX = 4000; // leave margin under 4096 for safety
const header = `🤖 <b>Agent is waiting for feedback</b>\n<i>Session: ${this.escapeHtml(params.sessionId.slice(0, 20))}…</i>\n\n`;
const footer = `\n<a href="${params.feedbackUrl}">Open in browser</a>`;
const sessionLabel = params.sessionAlias && params.sessionAlias !== params.sessionId
? this.escapeHtml(params.sessionAlias)
: `${this.escapeHtml(params.sessionId.slice(0, 20))}…`;
const header = `🤖 <b>Agent is waiting for feedback</b>\n<i>Session: ${sessionLabel}</i>\n\n`;

if (!params.context) {
return [header + footer];
return [header.trimEnd()];
}

const contextHtml = this.markdownToTelegramHtml(params.context);

// If it all fits in one message, send as one
const singleMessage = header + contextHtml + footer;
const singleMessage = header + contextHtml;
if (singleMessage.length <= TELEGRAM_MAX) {
return [singleMessage];
}
Expand Down Expand Up @@ -453,22 +455,19 @@ export class TelegramChannel implements NotificationChannel {
current = chunk;
}

// Append footer to last chunk if it fits, otherwise make a new part
if (current.length + footer.length <= TELEGRAM_MAX) {
parts.push(current + footer);
} else {
parts.push(current);
parts.push(footer);
}
parts.push(current);

return parts;
}


private formatFYIParts(params: FYIParams): string[] {
const TELEGRAM_MAX = 4000;
const header = `📋 <b>Agent Status Update</b>\n<i>Session: ${this.escapeHtml(params.sessionId.slice(0, 20))}…</i>\n\n`;
const footer = `\n\n<i>ℹ️ No response needed — this is an informational update.</i>\n<a href="${params.feedbackUrl}">Open in browser</a>`;
const sessionLabel = params.sessionAlias && params.sessionAlias !== params.sessionId
? this.escapeHtml(params.sessionAlias)
: `${this.escapeHtml(params.sessionId.slice(0, 20))}…`;
const header = `📋 <b>Agent Status Update</b>\n<i>Session: ${sessionLabel}</i>\n\n`;
const footer = `\n\n<i>ℹ️ No response needed — this is an informational update.</i>`;
const contextHtml = this.markdownToTelegramHtml(params.context);

const singleMessage = header + contextHtml + footer;
Expand Down Expand Up @@ -515,10 +514,7 @@ export class TelegramChannel implements NotificationChannel {
return parts;
}

private buildKeyboard(
sessionId: string,
_feedbackUrl: string
): InlineKeyboard {
private buildKeyboard(sessionId: string): InlineKeyboard {
return new InlineKeyboard()
.text("👍 Approve", `fb:approve:${sessionId.slice(0, 50)}`)
.text("👎 Reject", `fb:reject:${sessionId.slice(0, 50)}`)
Expand Down
30 changes: 30 additions & 0 deletions feedback-html-composer-history-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,36 @@ export const FEEDBACK_HTML_COMPOSER_HISTORY_SCRIPT = `
updateHistoryCollapseUi();
updateHistoryJumpVisibility();

// ── Agent context panel ──
let agentContextCollapsed = localStorage.getItem(STORAGE_AGENT_CONTEXT_COLLAPSED) === '1';
showAgentContextEl.checked = localStorage.getItem(STORAGE_SHOW_AGENT_CONTEXT) === '1';
let lastAgentContext = null;

function updateAgentContextPanel(context) {
lastAgentContext = context;
const show = showAgentContextEl.checked && context;
agentContextPanelEl.style.display = show ? '' : 'none';
if (show) {
agentContextContentEl.textContent = context;
agentContextContentEl.classList.toggle('collapsed', agentContextCollapsed);
agentContextToggleEl.textContent = agentContextCollapsed ? 'Expand' : 'Collapse';
agentContextToggleEl.setAttribute('aria-expanded', String(!agentContextCollapsed));
}
}

showAgentContextEl.addEventListener('change', () => {
localStorage.setItem(STORAGE_SHOW_AGENT_CONTEXT, showAgentContextEl.checked ? '1' : '0');
updateAgentContextPanel(lastAgentContext);
});

agentContextToggleEl.addEventListener('click', () => {
agentContextCollapsed = !agentContextCollapsed;
localStorage.setItem(STORAGE_AGENT_CONTEXT_COLLAPSED, agentContextCollapsed ? '1' : '0');
agentContextContentEl.classList.toggle('collapsed', agentContextCollapsed);
agentContextToggleEl.textContent = agentContextCollapsed ? 'Expand' : 'Collapse';
agentContextToggleEl.setAttribute('aria-expanded', String(!agentContextCollapsed));
});

// ── Audio context management ──
function getAudioContext() {
if (audioContext) return audioContext;
Expand Down
67 changes: 65 additions & 2 deletions feedback-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,58 @@ export const FEEDBACK_HTML = `<!DOCTYPE html>
70% { box-shadow: 0 0 0 8px rgba(63,185,80,0); }
100% { box-shadow: 0 0 0 0 rgba(63,185,80,0); }
}
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.wait-banner.waiting { animation: none; }
* { transition-duration: 0.01ms !important; }
}

/* Agent context panel */
.agent-context-panel {
margin: 0.5rem 0 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-surface);
overflow: hidden;
transition: border-color var(--transition-normal);
}
.agent-context-panel:has(.agent-context-content:not(:empty)) {
border-color: rgba(121,192,255,0.35);
}
.agent-context-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.75rem;
background: rgba(121,192,255,0.06);
border-bottom: 1px solid var(--border);
font-size: 0.78rem;
color: var(--fg-muted);
}
.agent-context-title {
font-weight: 500;
color: #79c0ff;
}
:root[data-theme="light"] .agent-context-title {
color: #0969da;
}
:root[data-theme="light"] .agent-context-panel:has(.agent-context-content:not(:empty)) {
border-color: rgba(9,105,218,0.3);
}
:root[data-theme="light"] .agent-context-header {
background: rgba(9,105,218,0.04);
}
.agent-context-content {
padding: 0.6rem 0.75rem;
font-size: 0.82rem;
line-height: 1.55;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
color: var(--fg);
}
.agent-context-content:empty { display: none; }
.agent-context-content.collapsed { display: none; }
textarea {
width: 100%;
min-height: 200px;
Expand Down Expand Up @@ -341,6 +389,13 @@ ${FEEDBACK_HTML_ENHANCED_STYLES}
<div class="subtitle">Type your feedback below. Press <kbd>Cmd+Enter</kbd> to submit. <span class="connection-status" id="connection-status"><span class="connection-dot" id="connection-dot"></span> <span id="connection-label">Connecting...</span></span></div>
<div id="active-session-summary" class="filepath">ACTIVE_SESSION_INFO</div>
<div id="wait-banner" class="wait-banner idle" role="status" aria-live="polite">Checking agent wait state...</div>
<div id="agent-context-panel" class="agent-context-panel" style="display:none;" role="region" aria-labelledby="agent-context-heading">
<div class="agent-context-header">
<span id="agent-context-heading" class="agent-context-title">Last assistant message</span>
<button type="button" id="agent-context-toggle" class="btn-secondary btn-small" aria-expanded="true" aria-controls="agent-context-content">Collapse</button>
</div>
<div id="agent-context-content" class="agent-context-content"></div>
</div>
<div class="layout">
<div class="main-column">
<div class="panel">
Expand Down Expand Up @@ -411,7 +466,8 @@ ${FEEDBACK_HTML_ENHANCED_STYLES}
<h2 id="settings-heading">Settings</h2>
<div class="notify-controls">
<label><input id="notify-sound" type="checkbox" checked /> Sound alert</label>
<label><input id="notify-desktop" type="checkbox" /> Desktop alert</label>
<label><input id="notify-desktop" type="checkbox" /> Desktop alert</label>
<label><input id="show-agent-context" type="checkbox" /> Show assistant messages</label>
<label>Mode:
<select id="notify-mode" aria-label="Notification mode">
<option value="focused">Focused session</option>
Expand Down Expand Up @@ -466,6 +522,10 @@ ${FEEDBACK_HTML_ENHANCED_STYLES}
const themeIconEl = document.getElementById('theme-icon');
const themeLabelEl = document.getElementById('theme-label');
const disconnectAfterEl = document.getElementById('disconnect-after');
const agentContextPanelEl = document.getElementById('agent-context-panel');
const agentContextContentEl = document.getElementById('agent-context-content');
const agentContextToggleEl = document.getElementById('agent-context-toggle');
const showAgentContextEl = document.getElementById('show-agent-context');

// ── SSE and state tracking ──
let uiEventSource = null;
Expand All @@ -486,6 +546,8 @@ ${FEEDBACK_HTML_ENHANCED_STYLES}
const STORAGE_NOTIFY_DESKTOP = 'tasksync.notify.desktop';
const STORAGE_NOTIFY_MODE = 'tasksync.notify.mode';
const STORAGE_HISTORY_COLLAPSED = 'tasksync.history.collapsed';
const STORAGE_SHOW_AGENT_CONTEXT = 'tasksync.show_agent_context';
const STORAGE_AGENT_CONTEXT_COLLAPSED = 'tasksync.agent_context.collapsed';
const STORAGE_DRAFT = pathSessionParam ? 'tasksync.draft.' + pathSessionParam : 'tasksync.draft';
const STORAGE_THEME = 'tasksync.theme';

Expand Down Expand Up @@ -1038,6 +1100,7 @@ ${FEEDBACK_HTML_COMPOSER_HISTORY_SCRIPT}
const targetSessionId = selectedSessionId || active;
detectNotificationTransitions(sessions, targetSessionId);
updateWaitBanner(targetSessionId, sessions);
updateAgentContextPanel(payload.agentContext || null);

const sessionSignature = JSON.stringify(sessions.map((s) => [s.sessionId, s.alias, s.waitingForFeedback, s.hasQueuedFeedback, s.remoteEnabled])) + ':' + selectedSessionId + ':' + channelsAvailable;
if (sessionSignature !== lastRenderedSessionSignature) {
Expand Down
10 changes: 6 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ function buildUiStatePayload(targetSessionId?: string) {
history: state?.history || [],
sessions,
channelsAvailable: channelManager?.hasChannels ?? false,
agentContext: state?.agentContext || null,
};
}

Expand Down Expand Up @@ -598,8 +599,8 @@ function registerServerHandlers(targetServer: Server) {
const context = sessionManager.getAgentContext(sessionId);
channelManager.notify({
sessionId,
sessionAlias: sessionManager.getSessionAlias(sessionId),
context: context ?? undefined,
feedbackUrl: `http://localhost:${uiPort}/session/${encodeURIComponent(sessionId)}`,
}).catch((err) => {
logEvent("error", "feedback.notify.error", { sessionId, error: String(err) });
});
Expand Down Expand Up @@ -911,10 +912,11 @@ function startFeedbackUI() {
res.status(400).json({ error: "Missing context" });
return;
}
const feedbackUrl = `http://localhost:${uiPort}`;
sessionManager.setAgentContext(sessionId, context);

logEvent("info", "api.status.fyi", { sessionId, contextLength: context.length });

await channelManager.sendFYI({ sessionId, context, feedbackUrl });
await channelManager.sendFYI({ sessionId, sessionAlias: sessionManager.getSessionAlias(sessionId), context });
res.json({ ok: true, sessionId });
});

Expand Down Expand Up @@ -1062,8 +1064,8 @@ function startFeedbackUI() {
const context = agentContext || sessionManager.getAgentContext(sessionId);
channelManager.notify({
sessionId,
sessionAlias: sessionManager.getSessionAlias(sessionId),
context: context ?? undefined,
feedbackUrl: `http://localhost:${uiPort}/session/${encodeURIComponent(sessionId)}`,
}).catch((err) => {
logEvent("error", "api.stream.notify.error", { sessionId, error: String(err) });
});
Expand Down
1 change: 1 addition & 0 deletions session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export class SessionManager {
setAgentContext(sessionId: string, context: string | null): void {
const state = this.getFeedbackState(sessionId);
state.agentContext = context;
this.events.onStateChange(sessionId);
}

getAgentContext(sessionId: string): string | null {
Expand Down