Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
758465c
call-kent: draft episodes + ai metadata
cursoragent Feb 22, 2026
9e1e9e9
test: fix recording form aria-describedby
cursoragent Feb 22, 2026
72756bc
test: adjust recording form validation expectations
cursoragent Feb 22, 2026
315c98a
prisma: update call seed for notes
cursoragent Feb 22, 2026
983c81f
call-kent: fallback ffmpeg stitching without assets
cursoragent Feb 22, 2026
74b8ca2
call-kent: admin sample response audio escape hatch
cursoragent Feb 22, 2026
766c8cd
call-kent: store stitched episode only; slim caller episodes
cursoragent Feb 22, 2026
b166c12
call-kent: fix publish safety + update e2e recording flow
cursoragent Feb 22, 2026
3d95466
call-kent: share redirect helper and fix migration newlines
cursoragent Feb 22, 2026
4dcdc2e
e2e: update call kent flow for notes + draft publish
cursoragent Feb 22, 2026
86bf571
call-kent: store call audio in r2 and proxy playback
cursoragent Feb 22, 2026
5cd6fa4
call-kent: fix audio proxy typing and import order
cursoragent Feb 22, 2026
748ae84
calls: stream proxy audio responses
cursoragent Feb 22, 2026
3dd8c6c
call-kent: migrate legacy call base64 to r2 on draft
cursoragent Feb 22, 2026
8218b25
calls admin: tidy draft audio mapping
cursoragent Feb 22, 2026
4020723
fix: address PR feedback (ranges, labels, overflow, ffmpeg cleanup)
cursoragent Feb 22, 2026
85a9d27
prisma: drop redundant call draft callId index
cursoragent Feb 22, 2026
d1f0105
refactor: drop in-PR episodeBase64 compatibility
cursoragent Feb 23, 2026
a920a89
fix: keep Discord call notifications within 2000 chars
cursoragent Feb 23, 2026
56dce25
Guard call-kent metadata generation with Cloudflare config check
cursoragent Feb 23, 2026
95484b8
refactor: remove config fallbacks for call-kent r2 and transistor
cursoragent Feb 23, 2026
90a7a53
env: require call-kent r2 bucket and drop runtime fallback
cursoragent Feb 23, 2026
c0febef
fix: validate notes length consistently
cursoragent Feb 23, 2026
36ca4d4
chore: update environment example and improve code formatting
kentcdodds Feb 23, 2026
293d4cd
fix: update default voice from "angus" to "luna" in text-to-speech fu…
kentcdodds Feb 23, 2026
931eae9
Reset draft polling state
cursoragent Feb 23, 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
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,14 @@ CLOUDFLARE_AI_EMBEDDING_MODEL=MOCK_CLOUDFLARE_AI_EMBEDDING_MODEL
# Used to auto-generate Transistor `transcript_text` for Call Kent episodes.
CLOUDFLARE_AI_TRANSCRIPTION_MODEL=MOCK_CLOUDFLARE_AI_TRANSCRIPTION_MODEL

# Feature: /calls/admin (AI title/description/keywords generation)
# Mocked: yes (when MOCKS=true)
# Optional (defaults to @cf/meta/llama-3.1-8b-instruct)
CLOUDFLARE_AI_CALL_KENT_METADATA_MODEL=MOCK_CLOUDFLARE_AI_CALL_KENT_METADATA_MODEL

# Feature: /calls/record (typed question -> AI TTS via Cloudflare Workers AI)
# Mocked: yes (when MOCKS=true)
# Optional (defaults to @cf/deepgram/aura-1)
# Optional (defaults to @cf/deepgram/aura-2-en)
CLOUDFLARE_AI_TEXT_TO_SPEECH_MODEL=MOCK_CLOUDFLARE_AI_TEXT_TO_SPEECH_MODEL

# Feature: /search/admin (semantic search manifests + ignore list)
Expand All @@ -121,6 +126,9 @@ R2_BUCKET=MOCK_R2_BUCKET
R2_ENDPOINT=MOCK_R2_ENDPOINT
R2_ACCESS_KEY_ID=MOCK_R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=MOCK_R2_SECRET_ACCESS_KEY
# Feature: /calls (store call audio + draft episodes in R2)
# Mocked: yes (writes blobs to .cache/cloudflare-r2 when MOCKS=true)
CALL_KENT_R2_BUCKET=MOCK_CALL_KENT_R2_BUCKET
# Optional: override ignore list object key
SEMANTIC_SEARCH_IGNORE_LIST_KEY=manifests/ignore-list.json

Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,8 @@ jobs:
timeout-minutes: 15
# Only run when we would actually deploy (keeps PRs fast).
if:
${{ github.ref == 'refs/heads/main' &&
github.event_name == 'push' && needs.changes.outputs.DEPLOYABLE == 'true'
}}
${{ github.ref == 'refs/heads/main' && github.event_name == 'push' &&
needs.changes.outputs.DEPLOYABLE == 'true' }}
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/index-semantic-youtube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ jobs:
# Optional: force-index specific videos. Can be set either as a
# workflow_dispatch input or as a repository variable.
YOUTUBE_VIDEO_IDS:
${{ github.event_name == 'workflow_dispatch' && inputs.youtube_video_ids || vars.YOUTUBE_VIDEO_IDS }}
${{ github.event_name == 'workflow_dispatch' &&
inputs.youtube_video_ids || vars.YOUTUBE_VIDEO_IDS }}
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AGENTS.md

This file is included in full context for every agent conversation. Keep it
tiny and stable.
This file is included in full context for every agent conversation. Keep it tiny
and stable.

## Editing policy

Expand Down
26 changes: 13 additions & 13 deletions app/components/app-hotkeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,12 @@ function AppHotkeys() {
setDialogOpen(false)
}, [location.pathname])

useHotkey(
hk.HOTKEY_TOGGLE_HOTKEYS_DIALOG,
() => setDialogOpen((o) => !o),
{
ignoreInputs: true,
preventDefault: true,
requireReset: true,
stopPropagation: true,
},
)
useHotkey(hk.HOTKEY_TOGGLE_HOTKEYS_DIALOG, () => setDialogOpen((o) => !o), {
ignoreInputs: true,
preventDefault: true,
requireReset: true,
stopPropagation: true,
})

React.useEffect(() => {
const sequenceManager = getSequenceManager()
Expand All @@ -88,8 +84,13 @@ function AppHotkeys() {
document.addEventListener('keydown', resetSequencesIfTyping, true)
document.addEventListener('focusout', resetSequencesIfTyping, true)

const unregisterCallbacks = NAVIGATION_HOTKEY_ROUTES.map(({ sequence, path }) =>
sequenceManager.register([...sequence], () => navigateToPath(path), navSequenceOptions),
const unregisterCallbacks = NAVIGATION_HOTKEY_ROUTES.map(
({ sequence, path }) =>
sequenceManager.register(
[...sequence],
() => navigateToPath(path),
navSequenceOptions,
),
)

return () => {
Expand All @@ -109,4 +110,3 @@ function AppHotkeys() {
}

export { AppHotkeys }

2 changes: 1 addition & 1 deletion app/components/arrow-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { clsx } from 'clsx'
import { motion, useReducedMotion, type Variant } from 'framer-motion'
import { Link, type LinkProps } from 'react-router';
import { Link, type LinkProps } from 'react-router'
import {
useElementState,
type ElementState,
Expand Down
2 changes: 1 addition & 1 deletion app/components/article-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { clsx } from 'clsx'
import { Link } from 'react-router';
import { Link } from 'react-router'
import { getImageBuilder, getImgProps } from '#app/images.tsx'
import { type MdxListItem, type Team } from '#app/types.ts'
import { getBannerAltProp, getBannerTitleProp } from '#app/utils/mdx.tsx'
Expand Down
88 changes: 47 additions & 41 deletions app/components/calls/__tests__/submit-recording-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ vi.mock('#app/utils/use-root-data.ts', () => ({
useRootData: () => mockUseRootData(),
}))

// In Vitest, the Vite macro plugin isn't installed, so mock the macro helper.
vi.mock('vite-env-only/macros', () => ({
serverOnly$: (fn: unknown) => fn,
}))

import { RecordingForm } from '#app/routes/resources/calls/save.tsx'

describe('RecordingForm', () => {
Expand All @@ -46,7 +51,9 @@ describe('RecordingForm', () => {
const createObjectURL = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:recording')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const revokeObjectURL = vi
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

try {
Expand All @@ -57,14 +64,10 @@ describe('RecordingForm', () => {
fireEvent.change(screen.getByLabelText('Title'), {
target: { value: 'A valid title' },
})
fireEvent.change(screen.getByLabelText('Description'), {
target: { value: 'A sufficiently long description for this call.' },
})
fireEvent.change(screen.getByLabelText('Keywords'), {
target: { value: 'test,call' },
})

const submitButton = screen.getByRole('button', { name: 'Submit Recording' })
const submitButton = screen.getByRole('button', {
name: 'Submit Recording',
})
const form = container.querySelector('form')
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
Expand Down Expand Up @@ -116,7 +119,9 @@ describe('RecordingForm', () => {
) {
if (eventName === 'loadend') {
loadEndListener =
typeof listener === 'function' ? () => listener(new Event('loadend')) : null
typeof listener === 'function'
? () => listener(new Event('loadend'))
: null
}
}
removeEventListener() {}
Expand All @@ -140,7 +145,9 @@ describe('RecordingForm', () => {
const createObjectURL = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:recording')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const revokeObjectURL = vi
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})

try {
const { container } = render(
Expand All @@ -150,12 +157,6 @@ describe('RecordingForm', () => {
fireEvent.change(screen.getByLabelText('Title'), {
target: { value: 'My First Call' },
})
fireEvent.change(screen.getByLabelText('Description'), {
target: { value: 'A sufficiently long description for this call.' },
})
fireEvent.change(screen.getByLabelText('Keywords'), {
target: { value: 'test,call' },
})
const form = container.querySelector('form')
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
Expand All @@ -175,10 +176,7 @@ describe('RecordingForm', () => {
expect(requestBody.get('intent')).toBe('create-call')
expect(requestBody.get('audio')).toBe('data:audio/wav;base64,ZmFrZQ==')
expect(requestBody.get('title')).toBe('My First Call')
expect(requestBody.get('description')).toBe(
'A sufficiently long description for this call.',
)
expect(requestBody.get('keywords')).toBe('test,call')
expect(requestBody.get('notes')).toBe('')

await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith(
Expand Down Expand Up @@ -214,7 +212,9 @@ describe('RecordingForm', () => {
) {
if (eventName === 'loadend') {
loadEndListener =
typeof listener === 'function' ? () => listener(new Event('loadend')) : null
typeof listener === 'function'
? () => listener(new Event('loadend'))
: null
}
}
removeEventListener() {}
Expand All @@ -228,8 +228,7 @@ describe('RecordingForm', () => {
json: vi.fn().mockResolvedValue({
fields: {
title: '',
description: 'desc',
keywords: 'a,b',
notes: '',
},
errors: {
title: 'Title is required',
Expand All @@ -245,13 +244,14 @@ describe('RecordingForm', () => {
const createObjectURL = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:recording')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const revokeObjectURL = vi
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})

const initialData = {
fields: {
title: 'Original title',
description: 'Original description',
keywords: 'test,call',
notes: 'Original notes',
},
errors: {},
}
Expand All @@ -261,8 +261,7 @@ describe('RecordingForm', () => {
const { container, rerender } = render(
<RecordingForm
audio={audio}
intent="publish-call"
callId="call-123"
intent="create-call"
data={{
fields: { ...initialData.fields },
errors: { ...initialData.errors },
Expand All @@ -281,8 +280,7 @@ describe('RecordingForm', () => {
rerender(
<RecordingForm
audio={audio}
intent="publish-call"
callId="call-123"
intent="create-call"
data={{
fields: { ...initialData.fields },
errors: { ...initialData.errors },
Expand All @@ -306,7 +304,9 @@ describe('RecordingForm', () => {
const createObjectURL = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:recording')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const revokeObjectURL = vi
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})

try {
const { container } = render(
Expand All @@ -322,7 +322,10 @@ describe('RecordingForm', () => {
expect(screen.getByText('80 characters left')).toBeInTheDocument()
const titleId = titleInput.getAttribute('id')
expect(titleId).toBeTruthy()
expect(titleInput).toHaveAttribute('aria-describedby', `${titleId}-countdown`)
expect(titleInput).toHaveAttribute(
'aria-describedby',
`${titleId}-countdown`,
)

fireEvent.change(titleInput, { target: { value: 'abcd' } })
expect(screen.getByText('76 characters left')).toBeInTheDocument()
Expand All @@ -335,17 +338,20 @@ describe('RecordingForm', () => {
screen.getByText('Title must be at least 5 characters'),
).toBeInTheDocument()

fireEvent.change(titleInput, { target: { value: 'abcde' } })
await waitFor(() =>
expect(
screen.queryByText('Title must be at least 5 characters'),
).not.toBeInTheDocument(),
)
const notesInput = screen.getByLabelText('Notes (optional)')
expect(notesInput).toHaveAttribute('maxLength', '5000')

// Submit should surface validation for untouched fields.
// Submit should surface validation and should not attempt to upload audio
// when validation fails.
fireEvent.change(titleInput, { target: { value: '' } })
fireEvent.submit(form as HTMLFormElement)
await screen.findByText('Description is required')
await screen.findByText('Keywords is required')
await screen.findByText('Title is required')
expect(titleInput.getAttribute('aria-describedby')).toContain(
`${titleId}-error`,
)
expect(titleInput.getAttribute('aria-describedby')).toContain(
`${titleId}-countdown`,
)
} finally {
createObjectURL.mockRestore()
revokeObjectURL.mockRestore()
Expand Down
14 changes: 9 additions & 5 deletions app/components/calls/episode-artwork-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ export function EpisodeArtworkPreview({
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-primary text-lg font-medium">Episode artwork</p>
<p className="text-primary text-lg font-medium">
Episode artwork
</p>
<p className="mt-1 text-sm text-gray-600 dark:text-slate-400">
{`By default we use your avatar from `}
<a
Expand Down Expand Up @@ -135,7 +137,10 @@ export function EpisodeArtworkPreview({
const wrapper = tooltipWrapperRef.current
if (!wrapper) return
const nextFocused = event.relatedTarget
if (nextFocused instanceof Node && wrapper.contains(nextFocused)) {
if (
nextFocused instanceof Node &&
wrapper.contains(nextFocused)
) {
return
}
setIsTooltipOpen(false)
Expand All @@ -150,15 +155,15 @@ export function EpisodeArtworkPreview({
onKeyDown={(event) => {
if (event.key === 'Escape') setIsTooltipOpen(false)
}}
className="text-primary inline-flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 text-xs leading-none opacity-80 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600"
className="text-primary inline-flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 text-xs leading-none opacity-80 hover:opacity-100 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-gray-600"
>
?
</button>
{isTooltipOpen ? (
<span
id={tooltipId}
role="tooltip"
className="absolute left-0 top-full z-20 mt-2 w-72 rounded-md bg-white p-3 text-sm text-gray-700 shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:text-slate-200 dark:ring-white/10"
className="absolute top-full left-0 z-20 mt-2 w-72 rounded-md bg-white p-3 text-sm text-gray-700 shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:text-slate-200 dark:ring-white/10"
>
{tooltip}
</span>
Expand All @@ -185,4 +190,3 @@ export function EpisodeArtworkPreview({
</section>
)
}

3 changes: 2 additions & 1 deletion app/components/character-countdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export function CharacterCountdown({
const remainingDisplay = Math.max(0, remaining)
let className = 'text-gray-500 dark:text-slate-400'
if (remaining <= 0) className = 'text-red-500'
else if (remaining <= warnAt) className = 'text-yellow-600 dark:text-yellow-500'
else if (remaining <= warnAt)
className = 'text-yellow-600 dark:text-yellow-500'

return (
<p
Expand Down
11 changes: 9 additions & 2 deletions app/components/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { type ErrorResponse, isRouteErrorResponse, useParams } from 'react-router';
import { getErrorMessage, useCapturedRouteError } from '#app/utils/misc-react.tsx'
import {
type ErrorResponse,
isRouteErrorResponse,
useParams,
} from 'react-router'
import {
getErrorMessage,
useCapturedRouteError,
} from '#app/utils/misc-react.tsx'

type StatusHandler = (info: {
error: ErrorResponse
Expand Down
2 changes: 1 addition & 1 deletion app/components/errors.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { clsx } from 'clsx'
import errorStack from 'error-stack-parser'
import * as React from 'react'
import { useMatches } from 'react-router';
import { useMatches } from 'react-router'
import { type MdxListItem } from '#app/types.ts'
import { getErrorMessage } from '#app/utils/misc.ts'
import { ArrowLink } from './arrow-button.tsx'
Expand Down
2 changes: 1 addition & 1 deletion app/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link } from 'react-router';
import { Link } from 'react-router'
import { getImgProps, type ImageBuilder } from '#app/images.tsx'
import { AnchorOrLink } from '#app/utils/misc-react.tsx'
import { useRootData } from '#app/utils/use-root-data.ts'
Expand Down
Loading
Loading