Skip to content

Commit 3f4a7c4

Browse files
committed
enhance(chat): show mode icon and label under thread titles
The sidebar conversation list gains a second line per thread: the icon and localized name of the mode the thread was last used in (lastChatMode), matching the design mockups. formatModeLabel is extracted to lib/config/modes.ts with the same contract the mode switcher and embedded settings already inline (known modes translate via chat.modes.*, free-form names fall back capitalized). Threads without a stored mode render exactly as before — no empty line. 3 new unit tests.
1 parent b5fd859 commit 3f4a7c4

3 files changed

Lines changed: 65 additions & 9 deletions

File tree

apps/chat/src/components/thread-list.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { createElement, useMemo, useState } from 'react'
77
import { TextField, useSidebar } from '@uzh-bf/design-system'
88
import { useTranslations } from 'next-intl'
99
import { useParams, useRouter } from 'next/navigation'
10-
import { getModeIcon } from '../lib/config/modes'
10+
import { formatModeLabel, getModeIcon } from '../lib/config/modes'
1111
import { useChatStore, type Thread } from '../stores/chatStore'
1212

1313
export const ThreadList: FC = () => {
@@ -297,18 +297,29 @@ const ThreadListItem: FC<ThreadListItemProps> = ({
297297
data-cy="chat-thread-select"
298298
onClick={onSelect}
299299
aria-current={isActive ? 'page' : undefined}
300-
className="flex min-w-0 flex-grow items-center gap-2 px-3 py-1 text-start"
300+
className="flex min-w-0 flex-grow flex-col gap-0.5 px-3 py-1 text-start"
301301
>
302-
{/* Badge the row with the icon of the mode the thread was last
302+
<p className="truncate text-sm">{getThreadTitle()}</p>
303+
{/* Second line: the icon + name of the mode the thread was last
303304
used in (D6). Rendered via createElement rather than bound to a
304305
capitalized local: assigning the looked-up icon in the render
305306
body reads to the React Compiler lint as defining a new
306-
component on every render. */}
307-
{thread.lastChatMode &&
308-
createElement(getModeIcon(thread.lastChatMode), {
309-
className: 'text-muted-foreground size-4 shrink-0',
310-
})}
311-
<p className="truncate text-sm">{getThreadTitle()}</p>
307+
component on every render. Omitted entirely (no empty line)
308+
when the thread has no stored mode, e.g. threads created
309+
before mode tracking shipped. */}
310+
{thread.lastChatMode && (
311+
<p
312+
data-cy="chat-thread-mode"
313+
className="text-muted-foreground flex items-center gap-1 text-xs"
314+
>
315+
{createElement(getModeIcon(thread.lastChatMode), {
316+
className: 'size-3 shrink-0',
317+
})}
318+
<span className="truncate">
319+
{formatModeLabel(t, thread.lastChatMode)}
320+
</span>
321+
</p>
322+
)}
312323
</button>
313324
<button
314325
type="button"

apps/chat/src/lib/config/modes.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Sparkles,
55
type LucideIcon,
66
} from 'lucide-react'
7+
import type { useTranslations } from 'next-intl'
78

89
// Presentation metadata for the chatbot mode keys exposed via `systemPrompts`.
910
// Modes are configured per chatbot, so only the well-known keys get a dedicated
@@ -23,3 +24,22 @@ export function isKnownMode(mode: string): mode is KnownMode {
2324
export function getModeIcon(mode: string): LucideIcon {
2425
return isKnownMode(mode) ? MODE_ICONS[mode] : Sparkles
2526
}
27+
28+
/**
29+
* Localized label for a chat mode, e.g. for the sidebar thread-list mode
30+
* line. Same contract as `mode-switcher.tsx` / `embedded-settings.tsx`
31+
* (isKnownMode + `chat.modes.*`, capitalized raw name otherwise) and
32+
* `lib/config/reasoning.ts`'s `formatReasoningEffort` — pulled out here so a
33+
* new call site doesn't have to re-inline the ternary.
34+
*/
35+
export function formatModeLabel(
36+
// `<never>` is the root-namespace instantiation, i.e. the one every caller
37+
// gets from a bare `useTranslations()`. Without it the generic resolves to a
38+
// union over every namespace and only relative keys typecheck.
39+
t: ReturnType<typeof useTranslations<never>>,
40+
mode: string
41+
): string {
42+
return isKnownMode(mode)
43+
? t(`chat.modes.${mode}`)
44+
: mode.charAt(0).toUpperCase() + mode.slice(1)
45+
}

apps/chat/test/mode-label.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { formatModeLabel } from '../src/lib/config/modes'
3+
4+
// Stands in for next-intl's `t`, using the same shape the real English
5+
// messages produce for the `chat.modes.*` keys this module reads.
6+
const t = ((key: string) => {
7+
if (key === 'chat.modes.tutor') return 'Tutor'
8+
if (key === 'chat.modes.explainer') return 'Explainer'
9+
return key
10+
}) as unknown as Parameters<typeof formatModeLabel>[0]
11+
12+
describe('formatModeLabel', () => {
13+
test('returns the localized label for a well-known mode', () => {
14+
expect(formatModeLabel(t, 'tutor')).toBe('Tutor')
15+
expect(formatModeLabel(t, 'explainer')).toBe('Explainer')
16+
})
17+
18+
test('falls back to the capitalized raw name for an unknown mode', () => {
19+
expect(formatModeLabel(t, 'socratic')).toBe('Socratic')
20+
})
21+
22+
test('only capitalizes the first character of an unknown mode', () => {
23+
expect(formatModeLabel(t, 'examPrep')).toBe('ExamPrep')
24+
})
25+
})

0 commit comments

Comments
 (0)