Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
96ce168
feat(meet): auto-fill meeting display name from connected account
graycyrus Jun 29, 2026
8c945c5
feat(meet): redesign meetings composer with platform selector
graycyrus Jun 29, 2026
17a19fd
feat(meet): history master-detail with rich transcript
graycyrus Jun 29, 2026
c9c9362
Merge remote-tracking branch 'upstream/main' into feat/meetings-redesign
graycyrus Jun 29, 2026
c0913c3
feat(meet): upcoming meetings table + meet_list_upcoming RPC
graycyrus Jun 29, 2026
c7c4483
feat(meet): persist per-meeting join policy + defaults drawer
graycyrus Jun 29, 2026
01f13e6
fix(meet): give Upcoming card a solid surface background
graycyrus Jun 30, 2026
c3b8c08
feat(meet): compact platform filter + default-select first call
graycyrus Jun 30, 2026
c9493b1
fix(meet): give the history section a solid card surface
graycyrus Jun 30, 2026
6bcfeef
fix(meet): address frontend code-review findings
graycyrus Jun 30, 2026
0dbb988
fix(meet): address backend code-review findings
graycyrus Jun 30, 2026
06f51fa
feat(meet): forward detected platform in calendar auto-join bot:join
graycyrus Jun 30, 2026
2a492cc
feat(meet): decouple calendar auto-join from notifications via watch_…
graycyrus Jun 30, 2026
4ca9a78
fix(meet): address frontend PR review findings
graycyrus Jun 30, 2026
f4a718f
fix(meet): address backend PR review findings
graycyrus Jun 30, 2026
de7d17a
Merge remote-tracking branch 'upstream/main' into feat/meetings-redesign
graycyrus Jun 30, 2026
8949a7b
fix(meet): port mascot-id fallback into MeetComposer after main merge
graycyrus Jun 30, 2026
5ad303d
fix(meet): harden MeetDefaultsDrawer load + per-setting save (review)
graycyrus Jun 30, 2026
b1c3adc
fix(meet): declare watch_calendar + platform_auto_join_policies in me…
graycyrus Jun 30, 2026
eb73804
style(meet): apply cargo fmt + prettier to meetings code
graycyrus Jun 30, 2026
3c7099f
fix(meet): include watch_calendar in the heartbeat engine planner gate
graycyrus Jun 30, 2026
84f86dc
test(meet): update gmeet connections E2E for the multi-platform composer
graycyrus Jun 30, 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
3 changes: 2 additions & 1 deletion app/src/components/composio/toolkitMeta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ function ComposioLogoBadge({
);
}

function composioLogoUrl(slug: string): string {
/** Composio-hosted logo URL for a given toolkit slug. */
export function composioLogoUrl(slug: string): string {
return `https://logos.composio.dev/api/${slug}`;
}

Expand Down
90 changes: 90 additions & 0 deletions app/src/components/meetings/ActionItemChecklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* ActionItemChecklist — renders a list of MeetCallActionItem objects.
*
* Executable items show a "Run with OpenHuman" button that navigates to /chat.
* Advisory items show only the description + metadata.
* Checked state is cosmetic (local only, not persisted).
*/
import debug from 'debug';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

import { useT } from '../../lib/i18n/I18nContext';
import type { MeetCallActionItem } from '../../services/meetCallService';
import Button from '../ui/Button';

const log = debug('meetings:action');

interface ActionItemChecklistProps {
items: MeetCallActionItem[];
}

export function ActionItemChecklist({ items }: ActionItemChecklistProps) {
const { t } = useT();
const navigate = useNavigate();
const [checked, setChecked] = useState<Record<number, boolean>>({});

if (items.length === 0) return null;

function handleCheck(index: number) {
setChecked(prev => ({ ...prev, [index]: !prev[index] }));
}

function handleRun(item: MeetCallActionItem) {
log('[action] run with OpenHuman clicked', {
description: item.description,
tool: item.tool_name,
});
// TODO: prefill chat with action item description — prefill not yet supported
void navigate('/chat');
}

return (
<div className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('skills.meetingBots.callActionItemsHeading')}
</p>
<ul className="mt-0.5 space-y-1.5 text-[11px]">
{items.map((item, i) => {
const isExecutable = item.kind === 'executable';
const meta = [
item.assignee?.trim() || undefined,
isExecutable ? item.tool_name?.trim() || undefined : undefined,
].filter(Boolean);

return (
<li key={i} className="flex items-start gap-2">
<input
type="checkbox"
checked={!!checked[i]}
onChange={() => handleCheck(i)}
aria-label={item.description}
className="mt-0.5 h-3 w-3 shrink-0 cursor-pointer rounded accent-primary-600"
/>
<div className="min-w-0 flex-1">
<span
className={
checked[i] ? 'text-content-faint line-through' : 'text-content-secondary'
}>
{item.description}
</span>
{meta.length > 0 && (
<span className="ml-1 text-content-faint text-[10px]">({meta.join(' · ')})</span>
)}
{isExecutable && (
<span className="ml-2">
<Button variant="tertiary" size="xs" onClick={() => handleRun(item)}>
{t('skills.meetingBots.history.runWithOpenHuman')}
</Button>
</span>
)}
</div>
</li>
);
})}
</ul>
</div>
);
}

export default ActionItemChecklist;
154 changes: 154 additions & 0 deletions app/src/components/meetings/ActiveMeetingBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Live/active meeting view — shown when `backendMeet.status` is `'joining'`,
* `'active'`, `'ended'`, or `'error'`.
*
* Extracted from `MeetingBotsCard` (previously `ActiveMeetingView`) to keep
* each component within the repo's ~500-line guideline. Behavior is identical
* to the original; it just lives in its own file now.
*/
import { useMemo, useState } from 'react';

import { type MascotFace, RiveMascot } from '../../features/human/Mascot';
import { useT } from '../../lib/i18n/I18nContext';
import { leaveBackendMeetBot } from '../../services/meetCallService';
import {
type BackendMeetHarnessEvent,
type BackendMeetReplyEvent,
type BackendMeetStatus,
resetBackendMeet,
selectBackendMeetLastHarness,
selectBackendMeetLastReply,
selectBackendMeetListenOnly,
selectBackendMeetStatus,
selectBackendMeetUrl,
} from '../../store/backendMeetSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import Button from '../ui/Button';

type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };

export interface ActiveMeetingBannerProps {
onToast?: (toast: Toast) => void;
}

function faceFromMeetState(
status: BackendMeetStatus,
lastReply: BackendMeetReplyEvent | null,
lastHarness: BackendMeetHarnessEvent | null
): MascotFace {
if (status === 'joining') return 'thinking';
if (status === 'error') return 'concerned';
if (status === 'ended') return 'happy';
if (lastHarness) return 'thinking';
if (lastReply) {
const e = (lastReply.emotion ?? '').toLowerCase();
if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit'))
return 'happy';
if (e.includes('celebrat') || e.includes('proud')) return 'celebrating';
if (e.includes('concern') || e.includes('worried') || e.includes('unsure')) return 'concerned';
if (e.includes('curious') || e.includes('interest')) return 'curious';
}
return 'idle';
}

export function ActiveMeetingBanner({ onToast }: ActiveMeetingBannerProps) {
const { t } = useT();
const dispatch = useAppDispatch();
const status = useAppSelector(selectBackendMeetStatus);
const meetUrl = useAppSelector(selectBackendMeetUrl);
const listenOnly = useAppSelector(selectBackendMeetListenOnly);
const lastReply = useAppSelector(selectBackendMeetLastReply);
const lastHarness = useAppSelector(selectBackendMeetLastHarness);
// selectBackendMeetError imported for parity; not used visually here — errors
// surface in the composer's inline alert during the error state.
const face = faceFromMeetState(status, lastReply, lastHarness);

const meetingCode = useMemo(() => {
if (!meetUrl) return '';
try {
const tail = new URL(meetUrl).pathname.replace(/^\/+/, '');
return tail || meetUrl;
} catch {
return meetUrl;
}
}, [meetUrl]);

const [leaving, setLeaving] = useState(false);

const handleLeave = async () => {
if (leaving) return;
setLeaving(true);
try {
await leaveBackendMeetBot('user-requested');
} catch (err) {
onToast?.({
type: 'error',
title: t('skills.meetingBots.couldNotStartTitle'),
message: String(err),
});
} finally {
setLeaving(false);
}
};

const statusText = (() => {
const base: Record<string, string> = {
joining: t('skills.meetingBots.liveStatusJoining'),
active: listenOnly
? t('skills.meetingBots.liveStatusListening')
: t('skills.meetingBots.liveStatusActive'),
ended: t('skills.meetingBots.liveStatusEnded'),
error: t('skills.meetingBots.liveStatusError'),
idle: '',
};
return base[status] ?? '';
})();

const canLeave = status === 'active' || status === 'joining';
const isDone = status === 'ended' || status === 'error';

return (
<div className="relative overflow-hidden rounded-2xl border border-primary-200/60 dark:border-primary-500/30 bg-gradient-to-br from-primary-50 via-white to-amber-50 dark:from-primary-500/15 dark:via-neutral-900 dark:to-amber-500/10 p-4 shadow-soft animate-fade-up">
<div className="flex items-center justify-between mb-3">
<span className="flex items-center gap-1.5 rounded-full bg-coral-500/10 dark:bg-coral-400/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-coral-600 dark:text-coral-400">
<span
className="h-1.5 w-1.5 rounded-full bg-coral-500 animate-pulse"
aria-hidden="true"
/>
{t('skills.meetingBots.liveBadge')}
</span>
{canLeave && (
<Button variant="secondary" size="sm" onClick={handleLeave} disabled={leaving}>
{t('skills.meetingBots.leaveButton')}
</Button>
)}
{isDone && (
<Button variant="secondary" size="sm" onClick={() => dispatch(resetBackendMeet())}>
{t('common.close')}
</Button>
)}
</div>
<div className="flex items-center gap-4">
<div className="w-20 h-20 flex-shrink-0">
<RiveMascot face={face} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-content">
{t('skills.meetingBots.liveTitle')}
</div>
<div className="mt-0.5 text-xs text-content-muted">{statusText}</div>
{meetingCode && (
<div className="mt-1 truncate font-mono text-[11px] text-content-secondary">
{meetingCode}
</div>
)}
{lastReply?.reply && (
<div className="mt-1.5 text-xs text-content-secondary line-clamp-2 italic">
&ldquo;{lastReply.reply}&rdquo;
</div>
)}
</div>
</div>
</div>
);
}
Loading
Loading