• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

agentic-dev-library / thumbcode / 21961208242

12 Feb 2026 07:29PM UTC coverage: 51.569% (-13.4%) from 64.947%
21961208242

push

github

web-flow
feat: complete Expo → Capacitor/Vite migration (#130)

## Summary

Full framework migration from Expo/React Native to Vite + Capacitor + React Router:

- **Framework**: Expo SDK 52 → Vite 6 + Capacitor 7
- **Routing**: expo-router → react-router-dom v7
- **Styling**: NativeWind → Tailwind CSS v4
- **Testing**: Jest → Vitest + @testing-library/react
- **Components**: React Native primitives → HTML/JSX (View→div, Text→span, etc.)
- **Build**: Metro bundler → Vite with HMR

### Key changes
- Removed all React Native and Expo dependencies
- Migrated 50+ components from RN to web HTML/JSX
- Rewrote 70 test files (844 tests passing)
- Replaced deploy-gh-pages.yml: Astro docs → Vite static site deployment
- Added android-release.yml: per-architecture debug APKs on GitHub releases
- Added ABI splits to build.gradle (armeabi-v7a, arm64-v8a, x86_64, universal)
- Fixed all biome lint, TypeScript, and E2E test issues
- Deleted dead `app/` directory (old Expo Router screens)

### CI Status
All critical checks passing: Lint & Type Check, Run Tests (844 passing), Build Web, Build Web + Capacitor Sync, E2E Tests (Web), Security Scan, CodeQL, Validate PR.

1329 of 2944 branches covered (45.14%)

Branch coverage included in aggregate %.

138 of 866 new or added lines in 82 files covered. (15.94%)

120 existing lines in 23 files now uncovered.

2188 of 3876 relevant lines covered (56.45%)

9.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/components/chat/ThreadList.tsx
1
/**
2
 * Thread List Component
3
 *
4
 * Displays a list of chat threads with pinned threads at the top.
5
 * Uses organic styling with visual indicators for unread messages.
6
 */
7

8
import {
9
  type ChatThread,
10
  selectPinnedThreads,
11
  selectRecentThreads,
12
  useChatStore,
13
} from '@thumbcode/state';
14
import { Badge } from '@/components/display';
15
import { Text } from '@/components/ui';
16
import { organicBorderRadius } from '@/lib/organic-styles';
17

18
/** Props for the ThreadList component */
19
interface ThreadListProps {
20
  /** Called when a thread is selected */
21
  onSelectThread: (threadId: string) => void;
22
  /** Called when the user wants to create a new thread */
23
  onCreateThread?: () => void;
24
}
25

26
/** Props for the ThreadItem component */
27
interface ThreadItemProps {
28
  /** The chat thread to display */
29
  thread: ChatThread;
30
  /** Called when the thread item is pressed */
31
  onPress: () => void;
32
}
33

34
/**
35
 * Format relative time for thread
36
 */
37
function formatRelativeTime(timestamp: string): string {
38
  const date = new Date(timestamp);
×
39
  const now = new Date();
×
40
  const diff = now.getTime() - date.getTime();
×
41

42
  const minutes = Math.floor(diff / 60000);
×
43
  const hours = Math.floor(minutes / 60);
×
44
  const days = Math.floor(hours / 24);
×
45

46
  if (minutes < 1) return 'Just now';
×
47
  if (minutes < 60) return `${minutes}m ago`;
×
48
  if (hours < 24) return `${hours}h ago`;
×
49
  if (days < 7) return `${days}d ago`;
×
50
  return date.toLocaleDateString();
×
51
}
52

53
/**
54
 * Get participant badge colors
55
 */
56
function getParticipantBadge(participant: ChatThread['participants'][number]) {
57
  const colorMap: Record<string, string> = {
×
58
    architect: 'bg-coral-500',
59
    implementer: 'bg-gold-500',
60
    reviewer: 'bg-teal-500',
61
    tester: 'bg-neutral-500',
62
  };
63
  return colorMap[participant] || 'bg-neutral-600';
×
64
}
65

66
function ThreadItem({ thread, onPress }: Readonly<ThreadItemProps>) {
67
  const hasUnread = thread.unreadCount > 0;
×
68
  const accessibilityLabel = [thread.title, hasUnread ? `${thread.unreadCount} unread` : '']
×
69
    .filter(Boolean)
70
    .join(', ');
71

72
  return (
×
73
    <button
74
      type="button"
75
      onClick={onPress}
76
      className="bg-surface-elevated p-4 mb-2 active:bg-neutral-700"
77
      aria-label={accessibilityLabel}
78
      aria-description="Open this thread"
79
      style={{ ...organicBorderRadius.card, transform: 'rotate(-0.2deg)' }}
80
    >
81
      <div className="flex-row items-start justify-between">
82
        <div className="flex-1 mr-3">
83
          {/* Title with unread indicator */}
84
          <div className="flex-row items-center mb-1">
85
            {thread.isPinned && (
×
86
              <div className="mr-2">
87
                <Badge variant="warning" size="sm">
88
                  Pinned
89
                </Badge>
90
              </div>
91
            )}
92
            <Text
93
              variant="display"
94
              size="base"
95
              className={hasUnread ? 'text-white' : 'text-neutral-200'}
×
96
              numberOfLines={1}
97
            >
98
              {thread.title}
99
            </Text>
100
          </div>
101

102
          {/* Participants */}
103
          <div className="flex-row items-center mb-1">
104
            {thread.participants
105
              .filter((p) => p !== 'user')
×
106
              .slice(0, 3)
107
              .map((participant, index) => (
NEW
108
                <div
×
109
                  key={participant}
110
                  className={`w-2 h-2 rounded-full ${getParticipantBadge(participant)} ${
111
                    index > 0 ? 'ml-1' : ''
×
112
                  }`}
113
                />
114
              ))}
115
            {thread.participants.length > 4 && (
×
116
              <Text size="xs" className="text-neutral-500 ml-1">
117
                +{thread.participants.length - 4}
118
              </Text>
119
            )}
120
          </div>
121

122
          {/* Timestamp */}
123
          <Text size="xs" className="text-neutral-500">
124
            {formatRelativeTime(thread.lastMessageAt)}
125
          </Text>
126
        </div>
127

128
        {/* Unread badge */}
129
        {hasUnread && (
×
130
          <div
131
            className="bg-coral-500 px-2 py-0.5 min-w-[20px] items-center"
132
            style={organicBorderRadius.pill}
133
          >
134
            <Text size="xs" weight="semibold" className="text-white">
135
              {thread.unreadCount > 99 ? '99+' : thread.unreadCount}
×
136
            </Text>
137
          </div>
138
        )}
139
      </div>
140
    </button>
141
  );
142
}
143

144
export function ThreadList({ onSelectThread, onCreateThread }: Readonly<ThreadListProps>) {
145
  const pinnedThreads = useChatStore(selectPinnedThreads);
×
146
  const recentThreads = useChatStore(selectRecentThreads);
×
147

148
  const allThreads = [...pinnedThreads, ...recentThreads];
×
149

150
  if (allThreads.length === 0) {
×
151
    return (
×
152
      <div className="flex-1 items-center justify-center p-6">
153
        <Text variant="display" size="lg" className="text-neutral-400 text-center mb-2">
154
          No conversations yet
155
        </Text>
156
        <Text size="sm" className="text-neutral-500 text-center mb-4">
157
          Start a new thread to collaborate with AI agents
158
        </Text>
159
        {onCreateThread && (
×
160
          <button
161
            type="button"
162
            onClick={onCreateThread}
163
            className="bg-coral-500 px-6 py-3 active:bg-coral-600"
164
            style={organicBorderRadius.button}
165
            aria-label="New Thread"
166
            aria-description="Create a new chat thread"
167
          >
168
            <Text weight="semibold" className="text-white">
169
              New Thread
170
            </Text>
171
          </button>
172
        )}
173
      </div>
174
    );
175
  }
176

177
  return (
×
178
    <div className="flex-1">
179
      {/* Header with new thread button */}
180
      <div className="flex-row justify-between items-center px-4 py-3 border-b border-neutral-700">
181
        <Text variant="display" size="lg" className="text-white">
182
          Conversations
183
        </Text>
184
        {onCreateThread && (
×
185
          <button
186
            type="button"
187
            onClick={onCreateThread}
188
            className="bg-teal-600 px-3 py-1.5 active:bg-teal-700"
189
            style={organicBorderRadius.button}
190
            aria-label="New Thread"
191
            aria-description="Create a new chat thread"
192
          >
193
            <Text size="sm" weight="semibold" className="text-white">
194
              + New
195
            </Text>
196
          </button>
197
        )}
198
      </div>
199

200
      {/* Pinned section */}
201
      {pinnedThreads.length > 0 && (
×
202
        <div className="mb-2">
203
          <Text size="xs" className="px-4 py-2 text-neutral-500 uppercase tracking-wider">
204
            Pinned
205
          </Text>
206
          {pinnedThreads.map((thread) => (
NEW
207
            <div key={thread.id} className="px-3">
×
208
              <ThreadItem thread={thread} onPress={() => onSelectThread(thread.id)} />
×
209
            </div>
210
          ))}
211
        </div>
212
      )}
213

214
      {/* Recent threads */}
215
      {recentThreads.length > 0 && (
×
216
        <Text size="xs" className="px-4 py-2 text-neutral-500 uppercase tracking-wider">
217
          Recent
218
        </Text>
219
      )}
220
      {recentThreads.map((item) => (
NEW
221
        <div key={item.id} className="px-3">
×
NEW
222
          <ThreadItem thread={item} onPress={() => onSelectThread(item.id)} />
×
223
        </div>
224
      ))}
225
    </div>
226
  );
227
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc