diff --git a/apps/web/src/hooks/useViewport.ts b/apps/web/src/hooks/useViewport.ts index 618dc93..91dc4b5 100644 --- a/apps/web/src/hooks/useViewport.ts +++ b/apps/web/src/hooks/useViewport.ts @@ -31,13 +31,48 @@ export function useViewport(): ViewportSnapshot { if (typeof window === 'undefined') return; const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`); const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`); - const update = () => setState(snapshot()); + const update = () => + setState((prev) => { + const next = snapshot(); + // Bail if nothing changed — visualViewport 'resize' fires on every + // URL-bar show/hide and scroll, and a fresh object would re-render + // every consumer needlessly. + if ( + prev.isMobile === next.isMobile && + prev.isTablet === next.isTablet && + prev.width === next.width + ) { + return prev; + } + return next; + }); + + // matchMedia 'change' alone is not enough on iOS Safari/Vivaldi: when a + // backgrounded tab is restored (bfcache) or refocused, no 'change' fires, + // and the width captured at first paint can be a stale/oversized value + // (iOS reports the wrong innerWidth for a beat before layout settles). That + // leaves isMobile=false on a phone, so the sidebar renders as a permanent + // desktop column with no way to close it. Re-snapshot on every signal that + // accompanies a rejoin/viewport correction, not just breakpoint crossings. + const onVisibility = () => { + if (document.visibilityState === 'visible') update(); + }; mobileMq.addEventListener('change', update); tabletMq.addEventListener('change', update); + window.addEventListener('resize', update); + window.addEventListener('orientationchange', update); + window.addEventListener('pageshow', update); + document.addEventListener('visibilitychange', onVisibility); + window.visualViewport?.addEventListener('resize', update); update(); return () => { mobileMq.removeEventListener('change', update); tabletMq.removeEventListener('change', update); + window.removeEventListener('resize', update); + window.removeEventListener('orientationchange', update); + window.removeEventListener('pageshow', update); + document.removeEventListener('visibilitychange', onVisibility); + window.visualViewport?.removeEventListener('resize', update); }; }, []); diff --git a/apps/web/src/lib/attachments.ts b/apps/web/src/lib/attachments.ts index bf8aeda..af06c5c 100644 --- a/apps/web/src/lib/attachments.ts +++ b/apps/web/src/lib/attachments.ts @@ -57,15 +57,17 @@ export function inferLanguage(filename: string): string | null { export function flattenToMessage(attachments: Attachment[], text: string): string { if (attachments.length === 0) return text; const blocks = attachments.map(a => { - const fence = '```' + (a.language ?? ''); - let header: string; - if (a.kind === 'lines') { - header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`; - } else if (a.kind === 'paste') { - header = `// from: pasted text (${a.content.split('\n').length} lines)`; - } else { - header = `// from: ${a.filename}`; + // Pasted text is raw context, not code from a file — insert it verbatim with + // no ``` fence or provenance header. The chip only exists to keep the textarea + // tidy while composing; on send it should be exactly what the user pasted. + if (a.kind === 'paste') { + return a.content; } + const fence = '```' + (a.language ?? ''); + const header = + a.kind === 'lines' + ? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}` + : `// from: ${a.filename}`; return `${fence}\n${header}\n${a.content}\n\`\`\``; }); return [...blocks, text].filter(Boolean).join('\n\n');