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
1 change: 0 additions & 1 deletion web/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ describe('App landing', () => {
it('renders Hero and ChatPanel, not ResumeTheme or GithubActivity', async () => {
render(<App />);
await waitFor(() => expect(screen.getByText('Verky Yi')).toBeInTheDocument());
expect(screen.getByText(/This page is an agent/i)).toBeInTheDocument();
expect(screen.getByLabelText('Chat')).toBeInTheDocument();
expect(screen.queryByTestId('resume-theme')).not.toBeInTheDocument();
expect(screen.queryByTestId('github-activity')).not.toBeInTheDocument();
Expand Down
44 changes: 8 additions & 36 deletions web/src/__tests__/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,6 @@ describe('ChatPanel — inline default state', () => {
expect(fetchMock).not.toHaveBeenCalled();
});

it('reset link appears only when messages exist', async () => {
vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example');
sessionStorage.setItem(
'agentfolio.chat.anthropic-fde-nyc',
JSON.stringify([{ role: 'assistant', segments: [{ kind: 'text', text: 'hi again' }] }]),
);
render(<ChatPanel slug="anthropic-fde-nyc" ownerName="Lianghui Yi" />);
expect(screen.getByRole('button', { name: /clear conversation/i })).toBeInTheDocument();
});

it('reset link is hidden when there are no messages', () => {
vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example');
render(<ChatPanel slug="anthropic-fde-nyc" ownerName="Lianghui Yi" />);
expect(screen.queryByRole('button', { name: /clear conversation/i })).not.toBeInTheDocument();
});
});

describe('ChatPanel — streaming send', () => {
Expand Down Expand Up @@ -310,18 +295,6 @@ describe('ChatPanel — persistence + reset', () => {
expect(screen.getByText('Welcome back')).toBeInTheDocument();
});

it('reset clears messages and sessionStorage', async () => {
vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example');
sessionStorage.setItem(
'agentfolio.chat.anthropic-fde-nyc',
JSON.stringify([{ role: 'assistant', segments: [{ kind: 'text', text: 'old' }] }]),
);
const user = userEvent.setup();
render(<ChatPanel slug="anthropic-fde-nyc" ownerName="Lianghui Yi" />);
await user.click(screen.getByRole('button', { name: /clear conversation/i }));
expect(screen.queryByText('old')).not.toBeInTheDocument();
expect(sessionStorage.getItem('agentfolio.chat.anthropic-fde-nyc')).toBeNull();
});
});

describe('ChatPanel — error handling', () => {
Expand Down Expand Up @@ -355,11 +328,10 @@ describe('ChatPanel — UX optimizations', () => {

it('caps response length and appends a truncation indicator', async () => {
vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example');
// Stream > 2000 chars across two deltas so truncation fires mid-stream.
const huge = 'x'.repeat(1500);
// Stream > 800 chars across two deltas so truncation fires mid-stream.
const fetchMock = vi.fn(async () => sseResponse([
`event: text\ndata: ${JSON.stringify({ delta: huge })}\n\n`,
`event: text\ndata: ${JSON.stringify({ delta: huge })}\n\n`,
`event: text\ndata: ${JSON.stringify({ delta: 'x'.repeat(900) })}\n\n`,
`event: text\ndata: ${JSON.stringify({ delta: 'x'.repeat(100) })}\n\n`,
'event: done\ndata: {}\n\n',
]));
vi.stubGlobal('fetch', fetchMock);
Expand All @@ -369,15 +341,15 @@ describe('ChatPanel — UX optimizations', () => {
await user.click(screen.getByRole('button', { name: /send/i }));

// Drip animation finishes and the truncation suffix appears.
await screen.findByText(/response truncated/i, {}, { timeout: 4000 });
await screen.findByText(/response truncated/i, {}, { timeout: 10000 });
const body = document.querySelector(
'.chatp-msg.assistant:not(.chatp-greeting) .chatp-msg-body',
) as HTMLElement | null;
expect(body).not.toBeNull();
// 2000 x's + suffix. Never more than 2000 x's.
expect(body!.textContent!.length).toBeLessThanOrEqual(2000 + ' … (response truncated)'.length);
expect(body!.textContent).toMatch(/^x{2000}/);
});
// 800 x's + suffix. Never more than 800 x's.
expect(body!.textContent!.length).toBeLessThanOrEqual(800 + ' … (response truncated)'.length);
expect(body!.textContent).toMatch(/^x{800}/);
}, 12000);

it('shows a streaming class on the active assistant bubble and drops it when done', async () => {
vi.stubEnv('VITE_CHAT_PROXY_URL', 'https://proxy.example');
Expand Down
8 changes: 3 additions & 5 deletions web/src/__tests__/Hero.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { Hero } from '../components/Hero';

describe('Hero', () => {
it('renders name, tagline, and explainer', () => {
it('renders name and tagline', () => {
render(
<Hero
name="Verky Yi"
Expand All @@ -13,17 +13,15 @@ describe('Hero', () => {
);
expect(screen.getByText('Verky Yi')).toBeInTheDocument();
expect(screen.getByText(/Product engineer building AI-native tools/)).toBeInTheDocument();
expect(screen.getByText(/This page is an agent/i)).toBeInTheDocument();
});

it('falls back to initials when no image', () => {
it('avatar is absent when no image', () => {
render(<Hero name="Verky Yi" />);
expect(screen.getByTestId('hero-avatar')).toHaveTextContent('VY');
expect(screen.queryByTestId('hero-avatar')).not.toBeInTheDocument();
});

it('renders without tagline', () => {
render(<Hero name="Verky Yi" />);
expect(screen.getByText('Verky Yi')).toBeInTheDocument();
expect(screen.getByText(/This page is an agent/i)).toBeInTheDocument();
});
});
20 changes: 0 additions & 20 deletions web/src/components/ChatPanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,6 @@
}
}

.chatp-header {
display: flex;
justify-content: flex-end;
align-items: center;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.chatp-clear {
font-family: inherit;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding: 0;
border-bottom: 1px dashed transparent;
}
.chatp-clear:hover { color: var(--accent-green); border-bottom-color: var(--accent-green); }

.chatp-messages {
flex: 1;
overflow-y: auto;
Expand Down
38 changes: 17 additions & 21 deletions web/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ const DEFAULT_SUGGESTIONS = [
];

// UX tuning for streamed responses.
const MAX_RESPONSE_CHARS = 2000;
const MAX_RESPONSE_CHARS = 800;
const TRUNCATION_SUFFIX = '… (response truncated)';
const DRIP_TICK_MS = 18;
const DRIP_TICK_MS = 40;
const DRIP_BASE_CHARS_PER_TICK = 1;
// Keep visible lag bounded: if the buffer runs ahead, reveal extra chars per tick.
const DRIP_BACKLOG_DIVISOR = 40;
Expand Down Expand Up @@ -157,6 +157,21 @@ export function ChatPanel({ slug, ownerName, tagline, email, profiles, greeting,
abortRef.current?.abort();
if (dripRef.current !== null) window.clearInterval(dripRef.current);
}, []);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const inset = Math.max(0, window.innerHeight - (vv.offsetTop + vv.height));
document.documentElement.style.setProperty('--keyboard-inset', inset + 'px');
};
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
document.documentElement.style.removeProperty('--keyboard-inset');
};
}, []);
useEffect(() => {
if (messages.length === 0) sessionStorage.removeItem(storageKey);
else sessionStorage.setItem(storageKey, JSON.stringify(messages));
Expand Down Expand Up @@ -189,17 +204,6 @@ export function ChatPanel({ slug, ownerName, tagline, email, profiles, greeting,
);
}

function reset() {
abortRef.current?.abort();
if (dripRef.current !== null) {
window.clearInterval(dripRef.current);
dripRef.current = null;
}
setMessages([]);
setStatus('idle');
sessionStorage.removeItem(storageKey);
}

function pickSuggestion(text: string) {
setDraft(text);
inputRef.current?.focus();
Expand Down Expand Up @@ -331,14 +335,6 @@ export function ChatPanel({ slug, ownerName, tagline, email, profiles, greeting,

return (
<section className="chatp" aria-label="Chat">
{messages.length > 0 && (
<div className="chatp-header">
<button type="button" className="chatp-clear" onClick={reset}>
clear conversation
</button>
</div>
)}

<div className="chatp-messages" ref={scrollContainerRef} onScroll={handleScroll}>
<div className="chatp-msg assistant chatp-greeting" data-testid="chat-greeting">
<span className="chatp-prompt">&gt;</span>
Expand Down
5 changes: 0 additions & 5 deletions web/src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,6 @@ export function Footer() {
<a href="https://github.com/verkyyi/agentfolio" target="_blank" rel="noopener noreferrer">
AgentFolio
</a>
{' · '}
Resume schema:{' '}
<a href="https://jsonresume.org/" target="_blank" rel="noopener noreferrer">
JSON Resume
</a>
</FooterWrapper>
);
}
1 change: 0 additions & 1 deletion web/src/components/Hero.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,3 @@
.hero-avatar img { width: 100%; height: 100%; object-fit: cover; }
.hero-name { margin: 0; font-size: 22px; font-weight: 600; }
.hero-tagline { margin: 4px 0 0; font-size: 14px; opacity: 0.72; }
.hero-explainer { margin: 12px 0 0; font-size: 13px; opacity: 0.6; max-width: 420px; }
20 changes: 5 additions & 15 deletions web/src/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,16 @@ export interface HeroProps {
image?: string;
}

function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('')
.slice(0, 2);
}

export function Hero({ name, tagline, image }: HeroProps) {
return (
<header className="hero">
<div className="hero-avatar" data-testid="hero-avatar">
{image ? <img src={image} alt={name} /> : <span>{initials(name)}</span>}
</div>
{image && (
<div className="hero-avatar" data-testid="hero-avatar">
<img src={image} alt={name} />
</div>
)}
<h1 className="hero-name">{name}</h1>
{tagline && <p className="hero-tagline">{tagline}</p>}
<p className="hero-explainer">
This page is an agent — ask it anything about my background, projects, or fit for a role.
</p>
</header>
);
}
2 changes: 1 addition & 1 deletion web/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ body {

.resume-viewport {
height: 100vh; /* fallback */
height: 100dvh;
height: calc(100dvh - var(--keyboard-inset, 0px));
overflow: hidden;
display: flex;
flex-direction: column;
Expand Down