Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
# Get yours at: https://app.kapa.ai/admin
PUBLIC_KAPA_INTEGRATION_ID=

# Optional custom handoff webhook target for the Ask AI "Create ticket" flow.
# If unset, handoff submissions run in preview mode and are logged server-side.
# If set, docs will POST full transcript payloads to this URL.
KAPA_HANDOFF_WEBHOOK_URL=

# RudderStack analytics — required for docs_404 event tracking and page analytics.
# Without these, no analytics events are sent and the weekly 404 monitor will
# report zero data. Set both in the Vercel project environment (all environments).
Expand Down
140 changes: 140 additions & 0 deletions src/components/KapaChatLauncher.css
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,146 @@
cursor: not-allowed;
}

.sl-kapa-handoff {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}

.sl-kapa-handoff__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
height: 2.5rem;
padding: 0 0.875rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: var(--sl-radius-sm);
background: var(--sl-color-black);
color: var(--sl-color-gray-1);
font: inherit;
font-size: var(--sl-text-sm);
font-weight: 500;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}

.sl-kapa-handoff__toggle svg {
width: 0.95rem;
height: 0.95rem;
flex: none;
}

.sl-kapa-handoff__toggle:hover {
border-color: var(--warp-control-border-hover);
background: var(--warp-control-bg-hover);
color: var(--sl-color-white);
}

.sl-kapa-handoff__toggle:focus-visible {
outline: 2px solid var(--sl-color-accent-high);
outline-offset: 2px;
}

.sl-kapa-handoff__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: var(--sl-radius-lg);
background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 28%);
}

.sl-kapa-handoff__form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}

.sl-kapa-handoff__form label span {
font-size: var(--sl-text-2xs);
color: var(--sl-color-gray-3);
}

.sl-kapa-handoff__form input,
.sl-kapa-handoff__form textarea {
width: 100%;
border: 1px solid var(--sl-color-gray-5);
border-radius: var(--sl-radius-sm);
background: var(--sl-color-black);
color: var(--sl-color-white);
padding: 0.5rem 0.625rem;
font: inherit;
font-size: var(--sl-text-sm);
}

.sl-kapa-handoff__form textarea {
resize: vertical;
min-height: 4rem;
}

.sl-kapa-handoff__form input::placeholder,
.sl-kapa-handoff__form textarea::placeholder {
color: var(--sl-color-gray-3);
}

.sl-kapa-handoff__form input:focus-visible,
.sl-kapa-handoff__form textarea:focus-visible {
outline: 2px solid var(--sl-color-accent-high);
outline-offset: 2px;
}

.sl-kapa-handoff__submit {
margin-top: 0.25rem;
height: 2.5rem;
border: 1px solid var(--sl-color-text-accent);
border-radius: var(--sl-radius-sm);
background: var(--sl-color-text-accent);
color: var(--sl-color-black);
font: inherit;
font-size: var(--sl-text-sm);
font-weight: 600;
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}

.sl-kapa-handoff__submit:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 18%);
background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 8%);
}

.sl-kapa-handoff__submit:focus-visible {
outline: 2px solid var(--sl-color-accent-high);
outline-offset: 2px;
}

.sl-kapa-handoff__submit:disabled {
opacity: 0.55;
cursor: not-allowed;
}

.sl-kapa-handoff__status {
margin: 0;
font-size: var(--sl-text-xs);
line-height: 1.4;
}

.sl-kapa-handoff__status--success {
color: #86efac;
}

.sl-kapa-handoff__status--error {
color: #fecaca;
}

/* Hide the reCAPTCHA badge */
.grecaptcha-badge {
visibility: hidden;
Expand Down
112 changes: 112 additions & 0 deletions src/components/KapaChatLauncher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
LuMessageSquare,
LuSend,
LuSquarePen,
LuTicket,
LuThumbsDown,
LuThumbsUp,
LuX,
Expand All @@ -32,6 +33,12 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
const [isOpen, setIsOpen] = useState(autoOpen);
const [query, setQuery] = useState('');
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const [showHandoffForm, setShowHandoffForm] = useState(false);
const [handoffEmail, setHandoffEmail] = useState('');
const [handoffNote, setHandoffNote] = useState('');
const [isSubmittingHandoff, setIsSubmittingHandoff] = useState(false);
const [handoffStatus, setHandoffStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [handoffStatusMessage, setHandoffStatusMessage] = useState('');
const [isAppleDevice, setIsAppleDevice] = useState(false);
const messagesRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDialogElement | null>(null);
Expand All @@ -45,6 +52,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
isGeneratingAnswer,
isPreparingAnswer,
submitQuery,
threadId,
} = useChat();
useEffect(() => {
setIsAppleDevice(isMac());
Expand Down Expand Up @@ -121,6 +129,60 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
addFeedback(questionAnswerId, reaction);
};

const buildConversationTranscript = () => {
if (!conversation.length) return 'No conversation history yet.';
return conversation
.map((qa, index) => {
const sources = qa.sources?.length
? `\nSources:\n${qa.sources.map((source) => `- ${source.title}: ${source.source_url}`).join('\n')}`
: '';
return `Q${index + 1}: ${qa.question}\nA${index + 1}: ${qa.answer || '(no answer generated yet)'}${sources}`;
})
.join('\n\n');
};

const submitHandoff = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmittingHandoff) return;
setIsSubmittingHandoff(true);
setHandoffStatus('idle');
setHandoffStatusMessage('');
try {
const response = await fetch('/kapa-handoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
replyToEmail: handoffEmail.trim(),
note: handoffNote.trim(),
pageUrl: window.location.href,
threadId,
conversationCount: conversation.length,
conversationTranscript: buildConversationTranscript(),
askedAt: new Date().toISOString(),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.error || 'Unable to submit handoff request.');
}
setHandoffStatus('success');
setHandoffStatusMessage(
data?.mode === 'preview'
? 'Ticket preview captured. Configure webhook env vars to send live emails.'
: 'Ticket submitted. A team member can follow up via your email.'
);
setHandoffNote('');
setShowHandoffForm(false);
} catch (submitError) {
setHandoffStatus('error');
setHandoffStatusMessage(
submitError instanceof Error ? submitError.message : 'Unable to submit handoff request.'
);
} finally {
setIsSubmittingHandoff(false);
}
};

const openPanel = () => {
// If the Pagefind search dialog is open with a query typed in, hand
// that query off to Kapa's input. Both entry points (the ⌘+I / Ctrl+I
Expand Down Expand Up @@ -304,6 +366,56 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
<LuSend aria-hidden="true" />
</button>
</form>
<div className="sl-kapa-handoff">
<button
type="button"
className="sl-kapa-handoff__toggle"
onClick={() => {
setShowHandoffForm((current) => !current);
setHandoffStatus('idle');
setHandoffStatusMessage('');
}}
aria-expanded={showHandoffForm}
>
<LuTicket aria-hidden="true" />
<span>Create ticket</span>
</button>
{showHandoffForm ? (
<form className="sl-kapa-handoff__form" onSubmit={submitHandoff}>
<label>
<span>Your email</span>
<input
type="email"
value={handoffEmail}
onChange={(event) => setHandoffEmail(event.target.value)}
placeholder="you@company.com"
required
/>
</label>
<label>
<span>Optional note</span>
<textarea
value={handoffNote}
onChange={(event) => setHandoffNote(event.target.value)}
placeholder="Share any extra context for the team."
rows={3}
/>
</label>
<button
type="submit"
className="sl-kapa-handoff__submit"
disabled={isSubmittingHandoff || !handoffEmail.trim()}
>
{isSubmittingHandoff ? 'Submitting…' : 'Send transcript'}
</button>
</form>
) : null}
{handoffStatus !== 'idle' ? (
<p className={`sl-kapa-handoff__status sl-kapa-handoff__status--${handoffStatus}`}>
{handoffStatusMessage}
</p>
) : null}
</div>
<div className="sl-kapa-meta">
<p className="sl-kapa-attribution">
Powered by{' '}
Expand Down
Loading
Loading