web: fix mobile nav stuck-open on rejoin + paste-chip code fence
useViewport re-syncs the snapshot on pageshow/visibilitychange/resize/orientationchange — iOS reported a stale width on backgrounded-tab restore, leaving isMobile=false so the sidebar rendered as a permanent column with no close affordance. flattenToMessage now inserts pasted-text chips verbatim instead of wrapping them in a triple-backtick fence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,13 +31,48 @@ export function useViewport(): ViewportSnapshot {
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
|
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 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);
|
mobileMq.addEventListener('change', update);
|
||||||
tabletMq.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();
|
update();
|
||||||
return () => {
|
return () => {
|
||||||
mobileMq.removeEventListener('change', update);
|
mobileMq.removeEventListener('change', update);
|
||||||
tabletMq.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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -57,15 +57,17 @@ export function inferLanguage(filename: string): string | null {
|
|||||||
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
||||||
if (attachments.length === 0) return text;
|
if (attachments.length === 0) return text;
|
||||||
const blocks = attachments.map(a => {
|
const blocks = attachments.map(a => {
|
||||||
const fence = '```' + (a.language ?? '');
|
// Pasted text is raw context, not code from a file — insert it verbatim with
|
||||||
let header: string;
|
// no ``` fence or provenance header. The chip only exists to keep the textarea
|
||||||
if (a.kind === 'lines') {
|
// tidy while composing; on send it should be exactly what the user pasted.
|
||||||
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
|
if (a.kind === 'paste') {
|
||||||
} else if (a.kind === 'paste') {
|
return a.content;
|
||||||
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
|
|
||||||
} else {
|
|
||||||
header = `// from: ${a.filename}`;
|
|
||||||
}
|
}
|
||||||
|
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 `${fence}\n${header}\n${a.content}\n\`\`\``;
|
||||||
});
|
});
|
||||||
return [...blocks, text].filter(Boolean).join('\n\n');
|
return [...blocks, text].filter(Boolean).join('\n\n');
|
||||||
|
|||||||
Reference in New Issue
Block a user