Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
22ac94c
feat(support): add Kevin AI support chat to the studio footer
AnkhHeart Aug 20, 2026
5e67963
feat(v2): point Kevin support at /v2 and expose OBS tools to the agent
AnkhHeart Aug 21, 2026
b5d24dc
fix(support): render the knowledge-source link footer
AnkhHeart Aug 22, 2026
1c9fc08
style(v2): let Prettier own the local protocol.ts copy
AnkhHeart Aug 22, 2026
e74da0c
chore(v2): sync protocol contract to tool version 2
AnkhHeart Aug 22, 2026
3f5a7fd
feat(v2): update tool protocol version to 3 with poll management enha…
AnkhHeart Aug 24, 2026
7c8b7b9
Add the agent's support tools: mic filters, health, diagnostics, ticket
AnkhHeart Aug 27, 2026
58d7852
Show the interaction quota in support chat, and offer Ultra at the limit
AnkhHeart Aug 27, 2026
f379d14
Redesign Kevin support chat to match Figma: reposition footer icon, r…
AnkhHeart Sep 4, 2026
0573c08
refactor: remove unused sendTrigger method from StreamAvatarApiService
AnkhHeart Sep 4, 2026
0327ccf
Address PR review: a11y, safe links, and error/log handling in Kevin …
AnkhHeart Sep 4, 2026
5bfbb8d
feat(tool): add watch_replay
AnkhHeart Sep 9, 2026
d9a33c3
feat(bubble): add kevin bubble when window is not visible for approvals
AnkhHeart Sep 9, 2026
3523aee
fix(pr): copilot review fixes
AnkhHeart Sep 9, 2026
66325af
chore(protocol): remove outdated comments and documentation
AnkhHeart Sep 9, 2026
a4c069a
fix(pr): comments implemented
AnkhHeart Sep 10, 2026
b7bc7d6
feat(analytics): track generic events
AnkhHeart Sep 10, 2026
27cb76c
fix(kevin-support): reset error state on run start
AnkhHeart Sep 10, 2026
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
6 changes: 6 additions & 0 deletions app/app-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export { StreamAvatarService } from 'services/stream-avatar/stream-avatar-servic
export { StreamAvatarApiService } from 'services/stream-avatar/stream-avatar-api-service';
export { AutomationsService } from 'services/stream-avatar/automations-service';
export { AutomationsEngineService } from 'services/stream-avatar/automations-engine-service';
export { KevinSupportService } from 'services/stream-avatar/kevin-support-service';
export { AgentToolsService } from 'services/stream-avatar/v2/agent-tools';
export { OnboardingV2Service } from 'services/onboarding/onboarding-v2';

// ONLINE SERVICES
Expand Down Expand Up @@ -214,6 +216,8 @@ import { VirtualWebcamService } from 'services/virtual-webcam';
import { StreamAvatarApiService } from 'services/stream-avatar/stream-avatar-api-service';
import { AutomationsService } from 'services/stream-avatar/automations-service';
import { AutomationsEngineService } from 'services/stream-avatar/automations-engine-service';
import { KevinSupportService } from 'services/stream-avatar/kevin-support-service';
import { AgentToolsService } from 'services/stream-avatar/v2/agent-tools';

export const AppServices = {
AppService,
Expand Down Expand Up @@ -305,4 +309,6 @@ export const AppServices = {
StreamAvatarApiService,
AutomationsService,
AutomationsEngineService,
KevinSupportService,
AgentToolsService,
};
95 changes: 95 additions & 0 deletions app/components-react/agent/KevinApprovalBubble.m.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
@import '../../styles/index';

/**
* A callout above the footer's Kevin icon, pointing down at it.
*
* `fixed`, not `absolute`: the footer clips on both axes (`overflow-y: hidden`
* on `.footer`, `overflow-x: auto` on it and on `.footer--left`), so a child
* positioned above the bar disappears. Fixed escapes ancestor overflow, and no
* ancestor sets a transform that would turn it back into a containing block.
* The component sets left/bottom from the icon's measured rect.
*
* Kept in the React tree rather than portalled to document.body: the theme is a
* class on the main window's root div, and everything below reads its CSS vars.
* Portalling out renders the card unstyled and light against a dark app.
*
* Deliberately the same fills, border and radius as the inline card in
* KevinSupport.m.less, so the two read as one component in two places.
*/
.bubble {
position: fixed;
width: 320px;
padding: 12px 14px;
background-color: var(--section-alt);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
color: var(--title);
font-size: 14px;
line-height: 1.5;
// Above the footer and the OBS display, below a real modal (.ant-modal-wrap
// is 1003 in Main.m.less).
z-index: 1002;
cursor: default;

// The arrow, pointing down at the icon. Two triangles so the border reads as
// a continuous outline rather than stopping where the bubble ends. Nothing
// here may sit inside an overflow container -- both are outside the box, and
// an `overflow` on this element would clip them and add a stray scrollbar.
&::before,
&::after {
content: '';
position: absolute;
top: 100%;
left: 18px;
width: 0;
height: 0;
border-style: solid;
}

&::before {
border-width: 8px 8px 0 8px;
border-color: var(--border) transparent transparent transparent;
}

&::after {
border-width: 7px 7px 0 7px;
border-color: var(--section-alt) transparent transparent transparent;
left: 19px;
}
}

/* Left accent marks a decision point, matching `.approval` in
KevinSupport.m.less. Stacked when one turn proposes more than one. */
.approval {
padding-left: 10px;
border-left: 3px solid var(--warning);

& + & {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
}

.summary {
font-weight: 600;
margin-bottom: 4px;
}

.warning {
color: var(--warning);
font-size: 12px;
margin-bottom: 8px;
}

.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;

button {
margin: 0;
}
}
114 changes: 114 additions & 0 deletions app/components-react/agent/KevinApprovalBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useLayoutEffect, useState } from 'react';
import { Services } from 'components-react/service-provider';
import { useVuex } from 'components-react/hooks';
import { $t } from 'services/i18n';
import styles from './KevinApprovalBubble.m.less';

interface Props {
/** The footer icon this points at. Measured, never positioned against. */
anchorRef: React.RefObject<HTMLElement>;
}

/**
* The approval prompt, shown above the Kevin icon in the studio footer.
*
* An approval used to force the support window open and focus it, which is an
* ambush mid-stream: a window jumps in front of whatever the streamer was doing,
* for a decision that is usually a single "yes". This carries the whole decision
* instead, so they answer without leaving the editor.
*
* Positioned `fixed` from the icon's measured rect rather than absolutely inside
* the footer, because the footer scrolls: `.footer` sets `overflow-y: hidden`
* and `overflow-x: auto`, and `.footer--left` another `overflow-x: auto`. A
* child positioned above the bar is clipped by both, which is invisible rather
* than merely misplaced. Fixed escapes that; it stays in the React tree so it
* keeps inheriting the theme class's CSS variables.
*
* It duplicates the card in KevinSupport.tsx rather than sharing one, because
* the two differ in everything but the three buttons: that one is a turn in a
* conversation, this one is a floating callout with no room for an avatar or a
* lead-in. Both call the same `resolveApproval`, which is the part that matters.
*/
export default function KevinApprovalBubble({ anchorRef }: Props) {
const { KevinSupportService, WindowsService } = Services;

const { pendingApprovals, chatFocused } = useVuex(() => ({
pendingApprovals: KevinSupportService.state.pendingApprovals,
// The state entry is deleted when the window closes, so `undefined` covers
// "closed" and `false` covers "open but behind something" in one read.
chatFocused: !!WindowsService.state['kevin-support']?.isFocused,
}));

// Nothing to add when the streamer is already looking at the chat — the card
// in there is the live surface, and two copies of one decision is worse than
// one in the wrong place.
const show = !chatFocused && pendingApprovals.length > 0;

const [anchor, setAnchor] = useState<DOMRect | null>(null);

useLayoutEffect(() => {
if (!show) return;
const measure = () => setAnchor(anchorRef.current?.getBoundingClientRect() ?? null);
measure();
// ponytail: re-measured on resize only. The icon also shifts when the
// performance metrics beside it change width, or if the footer is scrolled
// horizontally — watch those with a ResizeObserver if it ever looks off.
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [show, anchorRef]);

if (!show || !anchor) return <></>;

return (
// role="alert" rather than "alertdialog": the content is what should be
// announced, and an alertdialog would need a label naming a prompt whose
// whole text is already the summary below.
<div
className={styles.bubble}
role="alert"
style={{ left: anchor.left, bottom: window.innerHeight - anchor.top + 10 }}
>
{pendingApprovals.map(approval => (
<div key={approval.approvalId} className={styles.approval}>
<div className={styles.summary}>{approval.summary}</div>
{approval.risk === 'irreversible' && (
<div className={styles.warning}>{$t('This cannot be undone.')}</div>
)}
{approval.risk === 'external' && (
<div className={styles.warning}>{$t('This will be visible to your viewers.')}</div>
)}
<div className={styles.actions}>
<button
className="button button--action"
onClick={() =>
KevinSupportService.actions.resolveApproval(
approval.approvalId,
'approve',
'footer',
)
}
>
{$t('Allow once')}
</button>
<button
className="button button--default"
onClick={() =>
KevinSupportService.actions.resolveApproval(approval.approvalId, 'always', 'footer')
}
>
{$t('Always allow')}
</button>
<button
className="button button--default"
onClick={() =>
KevinSupportService.actions.resolveApproval(approval.approvalId, 'deny', 'footer')
}
>
{$t('Deny')}
</button>
</div>
</div>
))}
</div>
);
}
Loading
Loading