Skip to content

Commit df74443

Browse files
committed
fix: 22 defects found by an adversarial review of the eight features
Every one was verified by a second pass that tried to refute it. Ordered by what they cost. SENDING A TRUNCATED MESSAGE, AND LOSING THE REST. The collision toast's "Send" action called the `submit` closure captured when the warning was raised. Sonner freezes that handler, so eight seconds of further typing was invisible to it: it sent the older text, then cleared the field AND reset the draft dirty-check, so the rest was destroyed in the composer and deleted server-side. It now goes through a ref that always points at the current render. THE COLLISION WARNING COULD SILENTLY DISARM ITSELF. The acknowledged flag was set by SHOWING the warning rather than by acknowledging it, and never reset — so ignoring one toast suppressed the confirmation for the next send, including in a different conversation, since the composer instance is reused across routes. CITATIONS POINTED AT THE WRONG SOURCES. The answer says [3]; the source list renumbered from 1. Every reference was off, which is worse than showing no sources, because the entire promise of the feature is that you can check it. Sources are now keyed by the number the model actually used, and each shows the excerpt that was cited rather than the highest-ranked one in that conversation. A SAVED VIEW OPENED UNFILTERED. viewHref never emitted the new `seen` or `has` params, so a saved view built on either one showed everything while its sidebar badge counted the filtered set. CONTACT-NAME RETRIEVAL NEVER FIRED. `displayName % question` compares a short name against a whole sentence with length-normalised trigram similarity, which never clears the threshold. "What did Sarah say about the deposit" therefore never looked at Sarah's conversations at all. Now matched word by word, with stop words removed. A YEAR PARSED AS A CLOCK TIME. "mar 4 2026" — the time regex took "2026" as 20:26. And in "mar 9:30" the day matcher rejected the 9 for having a colon, then accepted the 30 as the day of the month. PROTOTYPE KEYS RESOLVED AS WEEKDAYS. `'constructor' in WEEKDAYS` is true, so "in 5 constructor" produced an Invalid Date that threw downstream in toISOString(). The lookup tables are null-prototype now. READ-NO-REPLY COUNTED OUR OWN TAPBACKS. An agent's reaction was treated as "a message they read and ignored", flagging conversations where we owed the reply. THE GHOST TEXT DID NOT LINE UP. The Textarea base carries `md:text-sm`, which twMerge keeps alongside `text-[13.5px]` — so above 768px the real text was 14px and the ghost 13.5px. A completion that wrapped was also clipped out of sight while Tab still accepted all of it; the field now grows to fit it. FIND COUNTED HITS IT COULD NOT SHOW YOU. System events and tapbacks matched, but they render as centred pills with no ref and no highlighting, so those stops scrolled nowhere. Overlapping matches were counted but collapsed by the highlighter, making "3 of 5" a lie; the matcher and the renderer now agree, and a test asserts they always will. Also: the note/reply toggle was dropped by the pagehide flush, which compared only the body — the one mechanism that exists to cover the debounce window would have turned an internal note into a public reply. The date picker committed on every keystroke that happened to form a valid value, discarded past times with no feedback, and had no `min`. The person panel's primary address had no ORDER BY, so an email sorting first silently removed the local time. 9 new tests, 205 total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RuKkB7AfSNCcwvubV99pDU
1 parent 189655a commit df74443

14 files changed

Lines changed: 273 additions & 74 deletions

File tree

apps/web/src/app/(app)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod
4343
p.set('priority', f.priorityIn.join(','));
4444
if (f.slaBreached) p.set('sla', 'breached');
4545
if (f.unreadOnly) p.set('unread', '1');
46+
if (f.readNoReply) p.set('seen', '1');
47+
if (f.has) p.set('has', String(f.has));
4648
if (f.sort && f.sort !== 'newest') p.set('sort', String(f.sort));
4749
const qs = p.toString();
4850
return qs ? `/inbox?${qs}` : '/inbox';

apps/web/src/components/inbox/ask-archive.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,16 @@ export function AskArchive() {
120120
From these conversations
121121
</p>
122122
<div className="divide-y rounded-xl border">
123-
{sources.map((s, i) => (
123+
{sources.map((s) => (
124124
<Link
125-
key={s.conversationId}
125+
key={s.index}
126126
href={`/inbox/${s.conversationId}`}
127127
className={cn(
128128
'flex items-start gap-2.5 px-3 py-2.5 transition-colors hover:bg-accent/60',
129129
)}
130130
>
131131
<span className="type-caption tabular mt-0.5 shrink-0 text-muted-foreground/60">
132-
[{i + 1}]
132+
[{s.index}]
133133
</span>
134134
<span className="min-w-0">
135135
<span className="type-title block truncate">{s.conversationName}</span>

apps/web/src/components/inbox/composer.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ export function Composer({
7777
const [pending, start] = useTransition();
7878
const ref = useRef<HTMLTextAreaElement>(null);
7979
const lastTypingPing = useRef(0);
80-
/** Set once the user has acknowledged a collision, so we ask only once. */
80+
/** Set once the user has ACKNOWLEDGED a collision, so we ask only once. */
8181
const confirmedCollision = useRef(false);
82+
/** Always the current render's submit — see the collision toast. */
83+
const submitRef = useRef<(scheduledFor?: Date) => void>(() => {});
8284
/** Inline completion shown after the caret; Tab accepts it. */
8385
const [completion, setCompletion] = useState('');
8486
const ghostRef = useRef<HTMLDivElement>(null);
@@ -129,6 +131,9 @@ export function Composer({
129131
loadedFor.current = conversationId;
130132
setBody(initialDraft?.body ?? '');
131133
setIsNote(initialDraft?.isPrivateNote ?? false);
134+
// Acknowledging a collision in one conversation must not disarm the
135+
// warning in the next one.
136+
confirmedCollision.current = false;
132137
}, [conversationId, initialDraft]);
133138

134139
/**
@@ -155,7 +160,7 @@ export function Composer({
155160
// draft would hurt most. keepalive lets the request outlive the page.
156161
useEffect(() => {
157162
const flush = () => {
158-
if (body === savedBody.current) return;
163+
if (body === savedBody.current && isNote === savedIsNote.current) return;
159164
navigator.sendBeacon?.(
160165
'/api/drafts',
161166
new Blob([JSON.stringify({ conversationId, body, isPrivateNote: isNote })], {
@@ -214,8 +219,14 @@ export function Composer({
214219
const el = ref.current;
215220
if (!el) return;
216221
el.style.height = 'auto';
217-
el.style.height = `${Math.min(el.scrollHeight, 180)}px`;
218-
}, [body]);
222+
// Measured against the GHOST as well: a completion that wraps onto a new
223+
// line would otherwise be clipped out of sight while Tab still accepted it.
224+
const needed = Math.max(el.scrollHeight, ghostRef.current?.scrollHeight ?? 0);
225+
el.style.height = `${Math.min(needed, 180)}px`;
226+
// A ghost that mounts while the field is already scrolled must start at the
227+
// same offset; the scroll event alone never fires in that case.
228+
syncGhostScroll();
229+
}, [body, completion]);
219230

220231
function submit(scheduledFor?: Date) {
221232
const trimmed = body.trim();
@@ -224,10 +235,19 @@ export function Composer({
224235
// Confirm rather than block. The person at the keyboard may know exactly
225236
// what they are doing — but they should have to say so, once.
226237
if (typingPeers.length > 0 && !isNote && !confirmedCollision.current) {
227-
confirmedCollision.current = true;
228238
toast.warning(`${firstNames(typingPeers)} is also replying`, {
229239
description: 'Send anyway?',
230-
action: { label: 'Send', onClick: () => submit(scheduledFor) },
240+
action: {
241+
label: 'Send',
242+
// Through a ref, NOT the captured `submit`. Sonner freezes the
243+
// handler at raise time, so calling the closure directly sent the
244+
// body as it was when the toast appeared and discarded everything
245+
// typed during the eight seconds it was on screen.
246+
onClick: () => {
247+
confirmedCollision.current = true;
248+
submitRef.current(scheduledFor);
249+
},
250+
},
231251
duration: 8000,
232252
});
233253
return;
@@ -249,6 +269,10 @@ export function Composer({
249269
});
250270
}
251271

272+
// Rebound every render so the collision toast, which captured its handler
273+
// once, always reaches the current body.
274+
submitRef.current = submit;
275+
252276
/** Insert a macro: render its variables server-side and run its actions. */
253277
function pickMacro(m: MacroOption) {
254278
start(async () => {
@@ -499,7 +523,7 @@ export function Composer({
499523
<div
500524
ref={ghostRef}
501525
aria-hidden
502-
className="pointer-events-none absolute inset-0 max-h-[180px] overflow-hidden whitespace-pre-wrap break-words px-1.5 py-1.5 text-[13.5px] leading-[inherit]"
526+
className="pointer-events-none absolute left-0 right-0 top-0 max-h-[180px] overflow-hidden whitespace-pre-wrap break-words px-1.5 py-1.5 text-[13.5px] leading-[inherit] md:text-[13.5px]"
503527
>
504528
<span className="invisible">{body}</span>
505529
<span className="text-muted-foreground/50">{completion}</span>
@@ -520,7 +544,7 @@ export function Composer({
520544
? 'Type a message… ⇥ to accept the suggested reply'
521545
: 'Type a message… / for macros'
522546
}
523-
className="relative max-h-[180px] min-h-[38px] w-full resize-none border-0 bg-transparent px-1.5 py-1.5 text-[13.5px] shadow-none focus-visible:ring-0"
547+
className="relative max-h-[180px] min-h-[38px] w-full resize-none border-0 bg-transparent px-1.5 py-1.5 text-[13.5px] shadow-none focus-visible:ring-0 md:text-[13.5px]"
524548
rows={1}
525549
/>
526550
</div>

apps/web/src/components/inbox/filter-bar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export function FilterBar({
154154
slaBreached: filters.slaBreached || undefined,
155155
unreadOnly: filters.unreadOnly || undefined,
156156
readNoReply: filters.readNoReply || undefined,
157+
has: filters.has as never,
157158
sort: filters.sort as never,
158159
},
159160
});

apps/web/src/components/inbox/message-thread.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ export function MessageThread({
234234
const out: FindMatch[] = [];
235235
for (const m of messages) {
236236
if (!m.body) continue;
237+
// System events and tapbacks render as centred pills with no ref and no
238+
// highlighting, so counting them produced hits that scrolled nowhere.
239+
if (m.authorType === 'system' || m.reactionType) continue;
237240
for (const offset of findOffsets(m.body, q)) out.push({ messageId: m.id, offset });
238241
}
239242
return out;

apps/web/src/components/inbox/time-picker.tsx

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,31 @@ export function CustomTimePicker({
3535
const parsed = useMemo(() => (text.trim() ? parseNaturalTime(text) : null), [text]);
3636
const invalid = text.trim().length > 0 && !parsed;
3737

38+
const [exact, setExact] = useState('');
39+
const [pickerError, setPickerError] = useState<string | null>(null);
40+
3841
function commit() {
3942
const at = parseNaturalTime(text);
43+
// Re-parsed at commit so "in 3 hours" means three hours from now, not from
44+
// when it was typed. If that now fails, say so rather than doing nothing
45+
// while the line above still shows the old successful reading.
4046
if (at) onPick(at);
47+
else if (text.trim()) setPickerError("Couldn't read that — try “tue 9am”");
48+
}
49+
50+
function commitExact() {
51+
if (!exact) return;
52+
const at = new Date(exact);
53+
if (Number.isNaN(at.getTime())) {
54+
setPickerError('That is not a valid date.');
55+
return;
56+
}
57+
if (at.getTime() <= Date.now()) {
58+
// Silently dropping a past value made the control look broken.
59+
setPickerError('Pick a time in the future.');
60+
return;
61+
}
62+
onPick(at);
4163
}
4264

4365
return (
@@ -47,7 +69,10 @@ export function CustomTimePicker({
4769
<input
4870
ref={inputRef}
4971
value={text}
50-
onChange={(e) => setText(e.target.value)}
72+
onChange={(e) => {
73+
setText(e.target.value);
74+
setPickerError(null);
75+
}}
5176
onKeyDown={(e) => {
5277
// Stop Enter/Escape reaching the menu that owns this popover.
5378
e.stopPropagation();
@@ -85,18 +110,41 @@ export function CustomTimePicker({
85110
: 'Type a time, or press Enter to confirm'}
86111
</p>
87112

88-
<input
89-
type="datetime-local"
90-
aria-label="Pick a date and time"
91-
onChange={(e) => {
92-
const v = e.target.value;
93-
if (!v) return;
94-
const at = new Date(v);
95-
if (!Number.isNaN(at.getTime()) && at.getTime() > Date.now()) onPick(at);
96-
}}
97-
onKeyDown={(e) => e.stopPropagation()}
98-
className="type-caption h-8 w-full rounded-lg border bg-surface px-2 text-muted-foreground outline-none focus:border-brand"
99-
/>
113+
{/* Confirmed explicitly rather than on change: a datetime-local fires as
114+
soon as the fields happen to form a valid value, so committing there
115+
acted on a date the user was still halfway through editing. */}
116+
<div className="flex items-center gap-1.5">
117+
<input
118+
type="datetime-local"
119+
aria-label="Pick a date and time"
120+
value={exact}
121+
min={new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
122+
.toISOString()
123+
.slice(0, 16)}
124+
onChange={(e) => {
125+
setExact(e.target.value);
126+
setPickerError(null);
127+
}}
128+
onKeyDown={(e) => {
129+
e.stopPropagation();
130+
if (e.key === 'Enter') {
131+
e.preventDefault();
132+
commitExact();
133+
}
134+
}}
135+
className="type-caption h-8 flex-1 rounded-lg border bg-surface px-2 text-muted-foreground outline-none focus:border-brand"
136+
/>
137+
<button
138+
type="button"
139+
onClick={commitExact}
140+
disabled={!exact}
141+
className="type-caption h-8 shrink-0 rounded-lg border px-2.5 transition-colors hover:bg-accent disabled:opacity-40"
142+
>
143+
Set
144+
</button>
145+
</div>
146+
147+
{pickerError && <p className="type-caption px-1 text-destructive">{pickerError}</p>}
100148
</div>
101149
);
102150
}

apps/web/src/lib/find-in-text.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ export function findOffsets(haystack: string, needle: string): number[] {
2424
const at = hay.indexOf(pin, from);
2525
if (at === -1) return out;
2626
out.push(at);
27-
// Advance by one, not by the needle length: overlapping matches ("aa" in
28-
// "aaa") are still two places a person would expect to be taken to.
29-
from = at + 1;
27+
// Advance by the needle length so this agrees exactly with highlightRuns,
28+
// which cannot render overlapping matches without duplicating the
29+
// characters they share. Counting a hit that has no highlight to jump to
30+
// makes "3 of 5" a lie.
31+
from = at + pin.length;
3032
}
3133
}
3234

@@ -42,7 +44,7 @@ export function highlightRuns(
4244
const runs: Array<{ text: string; match: boolean }> = [];
4345
let cursor = 0;
4446
for (const at of offsets) {
45-
// Overlapping matches would double-render the same characters.
47+
// findOffsets no longer emits overlaps; this keeps the invariant local.
4648
if (at < cursor) continue;
4749
if (at > cursor) runs.push({ text: text.slice(cursor, at), match: false });
4850
runs.push({ text: text.slice(at, at + needle.length), match: true });

apps/web/src/lib/parse-time.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,17 @@
1717
* than being asked to pick.
1818
*/
1919

20-
const WEEKDAYS: Record<string, number> = {
20+
const WEEKDAYS: Record<string, number> = Object.assign(Object.create(null), {
2121
sun: 0, sunday: 0,
2222
mon: 1, monday: 1,
2323
tue: 2, tues: 2, tuesday: 2,
2424
wed: 3, weds: 3, wednesday: 3,
2525
thu: 4, thur: 4, thurs: 4, thursday: 4,
2626
fri: 5, friday: 5,
2727
sat: 6, saturday: 6,
28-
};
28+
});
2929

30-
const MONTHS: Record<string, number> = {
30+
const MONTHS: Record<string, number> = Object.assign(Object.create(null), {
3131
jan: 0, january: 0,
3232
feb: 1, february: 1,
3333
mar: 2, march: 2,
@@ -40,14 +40,14 @@ const MONTHS: Record<string, number> = {
4040
oct: 9, october: 9,
4141
nov: 10, november: 10,
4242
dec: 11, december: 11,
43-
};
43+
});
4444

45-
const UNIT_MS: Record<string, number> = {
45+
const UNIT_MS: Record<string, number> = Object.assign(Object.create(null), {
4646
m: 60_000, min: 60_000, mins: 60_000, minute: 60_000, minutes: 60_000,
4747
h: 3_600_000, hr: 3_600_000, hrs: 3_600_000, hour: 3_600_000, hours: 3_600_000,
4848
d: 86_400_000, day: 86_400_000, days: 86_400_000,
4949
w: 604_800_000, week: 604_800_000, weeks: 604_800_000,
50-
};
50+
});
5151

5252
/** Default hour when a day is named without a time. */
5353
const DEFAULT_HOUR = 9;
@@ -72,7 +72,9 @@ function extractTime(input: string): { time: TimeOfDay | null; rest: string } {
7272
return { time: { hour, minute: 0 }, rest: input.replace(named[0], ' ') };
7373
}
7474

75-
const m = input.match(/\b(\d{1,2})(?::|\.)?(\d{2})?\s*(am|pm)?\b/);
75+
// The trailing (?!\d) matters: without it a bare year like "2025" matches as
76+
// 20:25, so "mar 4 2025" silently became 8:25pm.
77+
const m = input.match(/\b(\d{1,2})(?::|\.)?(\d{2})?\s*(am|pm)?\b(?!\d)/);
7678
if (!m) return { time: null, rest: input };
7779

7880
// A bare 1- or 2-digit number with no meridiem and no colon is only a time if
@@ -129,12 +131,22 @@ export function parseNaturalTime(input: string, now: Date = new Date()): Date |
129131
let working = raw;
130132
let monthIdx: number | null = null;
131133
let dayNum: number | null = null;
132-
const monthWord = working.match(/\b[a-z]{3,9}\b/g)?.find((w) => w in MONTHS);
134+
let yearNum: number | null = null;
135+
const monthWord = working.match(/\b[a-z]{3,9}\b/g)?.find((w) => Object.hasOwn(MONTHS, w));
133136
if (monthWord) {
134137
monthIdx = MONTHS[monthWord]!;
138+
// Pull an explicit year out before anything else can claim those digits.
139+
const yearMatch = working.match(/\b(19|20)\d{2}\b/);
140+
if (yearMatch) {
141+
yearNum = Number(yearMatch[0]);
142+
working = working.replace(yearMatch[0], ' ');
143+
}
135144
working = working.replace(new RegExp(`\\b${monthWord}\\b`), ' ');
136145
// A number is the day only if no meridiem or clock separator claims it.
137-
const dayMatch = working.match(/\b(\d{1,2})\b(?!\s*(?:am|pm)|[:.]\d)/);
146+
// The lookbehind stops the MINUTES of a clock time being read as the day:
147+
// in "mar 9:30" the 9 is rejected for having a colon after it, and without
148+
// this the 30 would then be accepted as the day of the month.
149+
const dayMatch = working.match(/(?<![:.\d])\b(\d{1,2})\b(?!\s*(?:am|pm)|[:.]\d)/);
138150
if (dayMatch) {
139151
dayNum = Number(dayMatch[1]);
140152
working = working.replace(dayMatch[0], ' ');
@@ -148,10 +160,13 @@ export function parseNaturalTime(input: string, now: Date = new Date()): Date |
148160
if (monthIdx !== null && dayNum !== null) {
149161
// Built from parts rather than mutated, so setting February on the 31st
150162
// cannot roll into March.
151-
const at = new Date(now.getFullYear(), monthIdx, dayNum);
163+
const at = new Date(yearNum ?? now.getFullYear(), monthIdx, dayNum);
152164
const result = atTime(at, time);
153-
// A date that has already passed this year means next year.
154-
if (result.getTime() <= now.getTime()) result.setFullYear(result.getFullYear() + 1);
165+
// A date that has already passed means next year — but only when the year
166+
// was inferred. An explicit "mar 4 2024" is a mistake, not next March.
167+
if (yearNum === null && result.getTime() <= now.getTime()) {
168+
result.setFullYear(result.getFullYear() + 1);
169+
}
155170
return result;
156171
}
157172

@@ -167,7 +182,7 @@ export function parseNaturalTime(input: string, now: Date = new Date()): Date |
167182
return atTime(d, time);
168183
}
169184

170-
const dayWord = words.find((w) => w in WEEKDAYS);
185+
const dayWord = words.find((w) => Object.hasOwn(WEEKDAYS, w));
171186
if (dayWord) {
172187
const target = WEEKDAYS[dayWord]!;
173188
const d = new Date(now);

0 commit comments

Comments
 (0)