Skip to content

Commit c6c9770

Browse files
chphchclaudehappy-otter
committed
fix(app): decouple unread indicator from the running status dot
The session-list status dot is overloaded: a session with unread results was overridden to a solid blue dot — the same color as the "thinking" (running) state, differing only by a pulse. A finished, idle session therefore looked like it was still running, and only reverted to its true green "online" dot after you opened it (which clears the unread flag). Decouple the two signals: the status dot now always reflects true liveness (disconnected/thinking/waiting/permission), and unread is shown separately as a bold title plus a small trailing accent dot. Blue is once again reserved exclusively for "running". Applies to both the session list rows and the compact active-sessions group. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent 8767b2b commit c6c9770

2 files changed

Lines changed: 42 additions & 24 deletions

File tree

packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,12 +225,16 @@ export function ActiveSessionsGroupCompact({ sessions, selectedSessionId }: Acti
225225
export const CompactSessionRow = React.memo(({ session, selected, showBorder }: { session: SessionRowData; selected?: boolean; showBorder?: boolean }) => {
226226
const styles = stylesheet;
227227
const { theme } = useUnistyles();
228-
const baseStatus = STATUS_CONFIG[session.state];
228+
// Status dot reflects true liveness only, never reusing the blue
229+
// "thinking/running" color for unread.
230+
const status = STATUS_CONFIG[session.state];
231+
// A session the agent is waiting on still owns the dot — that is attention,
232+
// not unread.
229233
const needsUserAction = session.state === 'permission_required' || session.state === 'input_required';
230-
// User action stays orange and pulsing even when the request also marked the session unread.
231-
const status = session.hasUnread && !needsUserAction
232-
? { ...baseStatus, color: '#007AFF', dotColor: '#007AFF', isPulsing: false, isConnected: baseStatus.isConnected }
233-
: baseStatus;
234+
// Unread is shown as a bold title, but only once the agent has stopped —
235+
// never while it's still running (thinking), so a re-activated session
236+
// doesn't read as unread mid-turn.
237+
const showUnreadTitle = session.hasUnread && session.state !== 'thinking';
234238
const navigateToSession = useNavigateToSession();
235239
const swipeableRef = React.useRef<Swipeable | null>(null);
236240
const swipeEnabled = Platform.OS !== 'web';
@@ -274,8 +278,6 @@ export const CompactSessionRow = React.memo(({ session, selected, showBorder }:
274278

275279
if (needsUserAction) {
276280
indicator = <StatusDot color={status.dotColor} isPulsing={status.isPulsing} />;
277-
} else if (session.hasUnread) {
278-
indicator = <StatusDot color={status.dotColor} isPulsing={false} />;
279281
} else if (session.state === 'waiting' && session.hasDraft) {
280282
indicator = (
281283
<Ionicons
@@ -312,7 +314,8 @@ export const CompactSessionRow = React.memo(({ session, selected, showBorder }:
312314
<Text
313315
style={[
314316
styles.sessionTitle,
315-
status.isConnected ? styles.sessionTitleConnected : styles.sessionTitleDisconnected
317+
status.isConnected ? styles.sessionTitleConnected : styles.sessionTitleDisconnected,
318+
showUnreadTitle && styles.sessionTitleUnread
316319
]}
317320
numberOfLines={2}
318321
>
@@ -510,6 +513,13 @@ const stylesheet = StyleSheet.create((theme) => ({
510513
sessionTitleDisconnected: {
511514
color: theme.colors.textSecondary,
512515
},
516+
sessionTitleUnread: {
517+
// Bold via the SemiBold face, not fontWeight: web sets
518+
// `font-synthesis: none` and bundles no Bold(700) face, so a numeric
519+
// fontWeight would render identically to the regular title.
520+
...Typography.default('semiBold'),
521+
color: theme.colors.text,
522+
},
513523
// 18 wide so the dot's center lines up with the center of the project
514524
// header's "+" button above the card, on both platform paddings.
515525
trailingIndicatorSlot: {

packages/happy-app/sources/components/SessionsList.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,8 @@ const stylesheet = StyleSheet.create((theme) => ({
199199
},
200200
sessionTitle: {
201201
fontSize: 15,
202-
fontWeight: '500',
203202
flex: 1,
204-
...Typography.default('semiBold'),
203+
...Typography.default('regular'),
205204
},
206205
sessionShortcutBadge: {
207206
flexShrink: 0,
@@ -213,6 +212,13 @@ const stylesheet = StyleSheet.create((theme) => ({
213212
sessionTitleDisconnected: {
214213
color: theme.colors.textSecondary,
215214
},
215+
sessionTitleUnread: {
216+
// Bold via the SemiBold face, not fontWeight: web sets
217+
// `font-synthesis: none` and bundles no Bold(700) face, so a numeric
218+
// fontWeight would render identically to the regular title.
219+
...Typography.default('semiBold'),
220+
color: theme.colors.text,
221+
},
216222
sessionSubtitleRow: {
217223
flexDirection: 'row',
218224
alignItems: 'center',
@@ -612,28 +618,29 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
612618
const styles = stylesheet;
613619
const navigateToSession = useNavigateToSession();
614620
const [actionsAnchor, setActionsAnchor] = React.useState<SessionActionsAnchor | null>(null);
615-
const baseStatus = STATUS_CONFIG[session.state];
616-
const needsUserAction = session.state === 'permission_required' || session.state === 'input_required';
617-
// User action stays orange and pulsing even when the request also marked the session unread.
618-
const status = session.hasUnread && !needsUserAction
619-
? { ...baseStatus, color: '#007AFF', dotColor: '#007AFF', isPulsing: false, isConnected: baseStatus.isConnected }
620-
: baseStatus;
621+
// Status dot reflects true liveness only, never reusing the blue
622+
// "thinking/running" color for unread.
623+
const status = STATUS_CONFIG[session.state];
624+
// Unread is shown as a bold title, but only once the agent has stopped —
625+
// never while it's still running (thinking), so a re-activated session
626+
// doesn't read as unread mid-turn.
627+
const showUnreadTitle = session.hasUnread && session.state !== 'thinking';
621628

622629
const vibingMessage = React.useMemo(() => {
623630
return vibingMessages[Math.floor(Math.random() * vibingMessages.length)].toLowerCase() + '…';
624631
}, [session.state]);
625632

633+
// No 'unread' status text: unread is carried by the bold title instead, so
634+
// the status line keeps reporting what the session is actually doing.
626635
const statusText = session.state === 'input_required'
627636
? t('status.inputRequired')
628637
: session.state === 'permission_required'
629638
? t('status.permissionRequired')
630-
: session.hasUnread
631-
? t('status.unread')
632-
: session.state === 'thinking'
633-
? vibingMessage
634-
: session.state === 'disconnected'
635-
? t('status.lastSeen', { time: formatLastSeen(session.activeAt!, false) })
636-
: t('status.online');
639+
: session.state === 'thinking'
640+
? vibingMessage
641+
: session.state === 'disconnected'
642+
? t('status.lastSeen', { time: formatLastSeen(session.activeAt!, false) })
643+
: t('status.online');
637644

638645
const handlePress = React.useCallback(() => {
639646
navigateToSession(session.id);
@@ -690,7 +697,8 @@ const SessionItem = React.memo(({ session, selected, isFirst, isLast, isSingle }
690697
<View style={styles.sessionTitleRow}>
691698
<Text style={[
692699
styles.sessionTitle,
693-
status.isConnected ? styles.sessionTitleConnected : styles.sessionTitleDisconnected
700+
status.isConnected ? styles.sessionTitleConnected : styles.sessionTitleDisconnected,
701+
showUnreadTitle && styles.sessionTitleUnread
694702
]} numberOfLines={1}>
695703
{session.name}
696704
</Text>

0 commit comments

Comments
 (0)