Skip to content

Commit a987a86

Browse files
committed
refactor: extract useStreamdownConfig hook and formatRelativeTime util
Eliminate duplicated Streamdown code-block config (~20 lines) shared between MessageBubble and StreamingMessage into a single reusable hook. Extract the duplicated relative-time formatting logic from MessageBubble and ContextSelector into a shared formatRelativeTime util.
1 parent 6170650 commit a987a86

5 files changed

Lines changed: 62 additions & 73 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useMemo, type ReactNode } from 'react';
2+
import { CodeBlock } from '../views/IrisChat/components/CodeBlock';
3+
4+
/**
5+
* Shared Streamdown component configuration for rendering fenced code blocks
6+
* via the CodeBlock component. Used by both MessageBubble (static) and
7+
* StreamingMessage (streaming).
8+
*/
9+
export function useStreamdownConfig() {
10+
return useMemo(() => ({
11+
code: ({ node, className, children, ...props }: {
12+
node?: unknown;
13+
className?: string;
14+
children?: ReactNode;
15+
[key: string]: unknown;
16+
}) => {
17+
const match = /language-(\w+)/.exec(className || '');
18+
const language = match ? match[1] : undefined;
19+
20+
if (language || className?.includes('language-')) {
21+
return (
22+
<CodeBlock language={language}>
23+
{String(children).replace(/\n$/, '')}
24+
</CodeBlock>
25+
);
26+
}
27+
28+
return (
29+
<code className={className} {...props}>
30+
{children}
31+
</code>
32+
);
33+
},
34+
}), []);
35+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Formats a timestamp as a relative time string ("just now", "5m ago", "2h ago", "3d ago").
3+
* For use in non-hook contexts (e.g., inside .map() calls).
4+
* For component-level relative time with auto-refresh, use useRelativeTime hook instead.
5+
*/
6+
export function formatRelativeTime(timestamp: number): string {
7+
const diff = Date.now() - timestamp;
8+
const seconds = Math.floor(diff / 1000);
9+
const minutes = Math.floor(seconds / 60);
10+
const hours = Math.floor(minutes / 60);
11+
const days = Math.floor(hours / 24);
12+
13+
if (seconds < 60) { return 'just now'; }
14+
if (minutes < 60) { return `${minutes}m ago`; }
15+
if (hours < 24) { return `${hours}h ago`; }
16+
return `${days}d ago`;
17+
}

extension/src/webview/views/IrisChat/components/ContextSelector.tsx

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useRef } from 'react';
22
import clsx from 'clsx';
33
import { useClickOutside } from '../../../hooks/useClickOutside';
4+
import { formatRelativeTime } from '../../../utils/formatRelativeTime';
45
import FolderGit2 from 'lucide-react/dist/esm/icons/folder-git-2';
56
import type { ChatContext, ChatSession, ContextItem } from '../types';
67
import type { ChatContextType } from '../../../../shared/types/context';
@@ -280,7 +281,7 @@ export function ContextSelector({
280281
</span>
281282
<span className={styles.sessionMeta}>
282283
{session.messageCount} messages •{' '}
283-
{formatTime(session.lastActivity)}
284+
{formatRelativeTime(session.lastActivity)}
284285
</span>
285286
</div>
286287
{session.id === activeSessionId && (
@@ -360,17 +361,3 @@ export function ContextSelector({
360361
</div>
361362
);
362363
}
363-
364-
function formatTime(timestamp: number): string {
365-
const now = Date.now();
366-
const diff = now - timestamp;
367-
const seconds = Math.floor(diff / 1000);
368-
const minutes = Math.floor(seconds / 60);
369-
const hours = Math.floor(minutes / 60);
370-
const days = Math.floor(hours / 24);
371-
372-
if (seconds < 60) {return 'just now';}
373-
if (minutes < 60) {return `${minutes}m ago`;}
374-
if (hours < 24) {return `${hours}h ago`;}
375-
return `${days}d ago`;
376-
}

extension/src/webview/views/IrisChat/components/MessageBubble.tsx

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { memo, useState, useMemo } from 'react';
33
import { Streamdown } from 'streamdown';
44
import clsx from 'clsx';
55
import { StreamingMessage } from './StreamingMessage';
6-
import { CodeBlock } from './CodeBlock';
6+
import { useStreamdownConfig } from '../../../hooks/useStreamdownConfig';
7+
import { formatRelativeTime } from '../../../utils/formatRelativeTime';
78
import type { ChatMessage } from '../types';
89
import styles from './MessageBubble.module.css';
910

@@ -23,21 +24,10 @@ function MessageBubbleComponent({
2324
const [hovering, setHovering] = useState(false);
2425
const isAssistant = message.role === 'assistant';
2526
const isUser = message.role === 'user';
27+
const streamdownComponents = useStreamdownConfig();
2628

2729
// Compute relative timestamp
28-
const relativeTime = useMemo(() => {
29-
const now = Date.now();
30-
const diff = now - message.timestamp;
31-
const seconds = Math.floor(diff / 1000);
32-
const minutes = Math.floor(seconds / 60);
33-
const hours = Math.floor(minutes / 60);
34-
const days = Math.floor(hours / 24);
35-
36-
if (seconds < 60) {return 'just now';}
37-
if (minutes < 60) {return `${minutes} min ago`;}
38-
if (hours < 24) {return `${hours}h ago`;}
39-
return `${days}d ago`;
40-
}, [message.timestamp]);
30+
const relativeTime = useMemo(() => formatRelativeTime(message.timestamp), [message.timestamp]);
4131

4232
const handleFeedback = (feedback: 'positive' | 'negative') => {
4333
if (message.id !== undefined) {
@@ -83,26 +73,7 @@ function MessageBubbleComponent({
8373
<div className={styles.content}>
8474
<Streamdown
8575
mode="static"
86-
components={{
87-
code: ({ node, className, children, ...props }: { node?: unknown; className?: string; children?: React.ReactNode; [key: string]: unknown }) => {
88-
const match = /language-(\w+)/.exec(className || '');
89-
const language = match ? match[1] : undefined;
90-
91-
if (language || className?.includes('language-')) {
92-
return (
93-
<CodeBlock language={language}>
94-
{String(children).replace(/\n$/, '')}
95-
</CodeBlock>
96-
);
97-
}
98-
99-
return (
100-
<code className={className} {...props}>
101-
{children}
102-
</code>
103-
);
104-
},
105-
}}
76+
components={streamdownComponents}
10677
>
10778
{message.content}
10879
</Streamdown>

extension/src/webview/views/IrisChat/components/StreamingMessage.tsx

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMemo } from 'react';
22
// @ts-expect-error - streamdown is ESM but TypeScript Node16 resolution complains (TS1479). esbuild handles at bundle time.
33
import { Streamdown } from 'streamdown';
4-
import { CodeBlock } from './CodeBlock';
4+
import { useStreamdownConfig } from '../../../hooks/useStreamdownConfig';
55
import styles from './StreamingMessage.module.css';
66

77
interface StreamingMessageProps {
@@ -11,6 +11,7 @@ interface StreamingMessageProps {
1111
export function StreamingMessage({ chunks }: StreamingMessageProps) {
1212
// Join all chunks into a single content string
1313
const content = useMemo(() => chunks.join(''), [chunks]);
14+
const streamdownComponents = useStreamdownConfig();
1415

1516
return (
1617
<div className={styles.streamingMessage}>
@@ -21,29 +22,7 @@ export function StreamingMessage({ chunks }: StreamingMessageProps) {
2122
animation: 'fadeIn',
2223
duration: 150,
2324
}}
24-
components={{
25-
code: ({ node, className, children, ...props }: { node?: unknown; className?: string; children?: React.ReactNode; [key: string]: unknown }) => {
26-
// Check if this is a fenced code block (has language class)
27-
const match = /language-(\w+)/.exec(className || '');
28-
const language = match ? match[1] : undefined;
29-
30-
// If it's a fenced code block, render with CodeBlock
31-
if (language || className?.includes('language-')) {
32-
return (
33-
<CodeBlock language={language}>
34-
{String(children).replace(/\n$/, '')}
35-
</CodeBlock>
36-
);
37-
}
38-
39-
// Otherwise, render as inline code
40-
return (
41-
<code className={className} {...props}>
42-
{children}
43-
</code>
44-
);
45-
},
46-
}}
25+
components={streamdownComponents}
4726
>
4827
{content}
4928
</Streamdown>

0 commit comments

Comments
 (0)