Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/mobile-web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,13 @@ interface FileCardProps {
const FileCard: React.FC<FileCardProps> = ({ path, onGetFileInfo, onDownload }) => {
const { t } = useI18n();
const [state, setState] = useState<FileCardState>({ status: 'loading' });
const [attempt, setAttempt] = useState(0);
const onGetFileInfoRef = useRef(onGetFileInfo);
onGetFileInfoRef.current = onGetFileInfo;

// The owning host resolves metadata on demand, so a failure is not final: a
// file can appear after the message that names it, and a target switch can
// fail the request. Keep the reason and make the lookup repeatable.
useEffect(() => {
let cancelled = false;
onGetFileInfoRef.current(path)
Expand All @@ -283,7 +287,12 @@ const FileCard: React.FC<FileCardProps> = ({ path, onGetFileInfo, onDownload })
setState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
});
return () => { cancelled = true; };
}, [path]);
}, [path, attempt]);

const handleRetry = useCallback(() => {
setState({ status: 'loading' });
setAttempt(value => value + 1);
}, []);

const handleClick = useCallback(async () => {
if (state.status !== 'ready' && state.status !== 'done') return;
Expand Down Expand Up @@ -311,10 +320,18 @@ const FileCard: React.FC<FileCardProps> = ({ path, onGetFileInfo, onDownload })
);
}
if (state.status === 'error') {
// The reason used to live only in `title`, which no touch host shows. Keep
// the failure readable and repeatable instead of a dead, dimmed card.
return (
<span className="file-card" data-status="error" title={state.message}>
<span className="file-card" data-status="error">
<span className="file-card__icon"><FileTextIcon size={20} /></span>
<span className="file-card__placeholder">{t('chat.fileUnavailable')}</span>
<span className="file-card__copy">
<span className="file-card__name">{t('chat.fileUnavailable')}</span>
<span className="file-card__reason">{state.message}</span>
</span>
<MobileButton appearance="plain" className="file-card__retry" onClick={handleRetry}>
{t('devices.retry')}
</MobileButton>
</span>
);
}
Expand Down
11 changes: 11 additions & 0 deletions src/mobile-web/src/styles/components/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,17 @@
white-space: pre-line;
overflow-wrap: anywhere;
animation: toastSlideIn 0.25s ease-out;
// This notice floats over the transcript, so the shared status surface (a 10%
// tint) would let the message behind it show through the text. Composite the
// tone tint onto an opaque raised surface instead.
--chat-toast-tint: var(--openbitfun-color-surface-subtle);
background-color: var(--openbitfun-color-surface-raised);
background-image: linear-gradient(var(--chat-toast-tint), var(--chat-toast-tint));
box-shadow: var(--openbitfun-shadow-base);

&[data-tone='info'] { --chat-toast-tint: var(--openbitfun-color-status-info-surface); }
&[data-tone='warning'] { --chat-toast-tint: var(--openbitfun-color-status-warning-surface); }
&[data-tone='danger'] { --chat-toast-tint: var(--openbitfun-color-status-danger-surface); }
}

@keyframes toastSlideIn {
Expand Down
27 changes: 26 additions & 1 deletion src/mobile-web/src/styles/components/markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,15 @@
cursor: default;
}

// A failed lookup is information, not decoration: dimming the card to 0.56
// made the reason unreadable. Show the failure tone instead.
.file-card[data-status='error'] {
opacity: 0.56;
border-color: var(--openbitfun-color-status-danger-border);
background: var(--openbitfun-color-status-danger-surface);
}

.file-card[data-status='error'] .file-card__name {
color: var(--openbitfun-color-status-danger-content);
}

.file-card__icon {
Expand Down Expand Up @@ -301,6 +308,24 @@
margin-block-start: 2px;
}

.file-card__reason {
display: -webkit-box;
margin-block-start: 2px;
overflow: hidden;
color: var(--openbitfun-color-content-secondary);
font-size: var(--openbitfun-type-support-font-size);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}

.file-card__retry {
min-inline-size: 44px;
min-block-size: 44px;
flex: 0 0 auto;
color: var(--openbitfun-color-status-danger-content);
font-size: var(--openbitfun-type-support-font-size);
}

.file-card__placeholder {
flex: 1;
opacity: 0.72;
Expand Down
41 changes: 41 additions & 0 deletions src/mobile-web/tests/mobile-ui-components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,47 @@ test('mobile transcript keeps one user bubble and projects file cards outside ma
assert.match(markdownStyles, /\.file-card\s*\{[\s\S]*?inline-size:\s*100%;/);
});

test('mobile file card failures stay readable and repeatable', async () => {
const markdown = await readFile(path.join(sourceDirectory, 'components/ChatMarkdown.tsx'), 'utf8');
const markdownStyles = await readFile(path.join(sourceDirectory, 'styles/components/markdown.scss'), 'utf8');

// The host resolves metadata on demand, so the card must expose both the
// failure reason and a way to ask again instead of latching the first error.
assert.match(markdown, /const \[attempt, setAttempt\] = useState\(0\);/);
assert.match(markdown, /\}, \[path, attempt\]\);/);
assert.match(markdown, /const handleRetry = useCallback\(\(\) => \{[\s\S]*?setAttempt\(value => value \+ 1\);[\s\S]*?\}, \[\]\);/);
assert.match(markdown, /className="file-card__reason">\{state\.message\}/);
assert.match(markdown, /className="file-card__retry"[\s\S]*?t\('devices\.retry'\)/);
assert.doesNotMatch(markdown, /data-status="error" title=/);

const errorCardStyles = markdownStyles.slice(
markdownStyles.indexOf(".file-card[data-status='error'] {"),
markdownStyles.indexOf('.file-card__icon {'),
);
assert.match(errorCardStyles, /background:\s*var\(--openbitfun-color-status-danger-surface\);/);
assert.doesNotMatch(errorCardStyles, /opacity:/);
assert.match(markdownStyles, /\.file-card__reason\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/);
});

test('chat notices stay readable above the transcript they float over', async () => {
const chatStyles = await readFile(path.join(sourceDirectory, 'styles/components/chat.scss'), 'utf8');
const feedback = await readFile(path.join(sourceDirectory, 'components/ChatFeedback.tsx'), 'utf8');

assert.match(feedback, /className="chat-page__toast"/);
const toastStyles = chatStyles.slice(
chatStyles.indexOf('.chat-page__toast {'),
chatStyles.indexOf('@keyframes toastSlideIn'),
);
// The shared status surface is a 10% tint; a floating notice needs an opaque
// base so the transcript behind it cannot bleed through the message text.
assert.match(toastStyles, /background-color:\s*var\(--openbitfun-color-surface-raised\);/);
assert.match(toastStyles, /background-image:\s*linear-gradient\(var\(--chat-toast-tint\), var\(--chat-toast-tint\)\);/);
for (const tone of ['info', 'warning', 'danger']) {
assert.match(toastStyles, new RegExp(`&\\[data-tone='${tone}'\\]`));
}
assert.doesNotMatch(toastStyles, /background:\s*var\(--openbitfun-color-status-(?:info|warning|danger)-surface\)/);
});


test('mobile viewport follows the keyboard, restores insets and leaves pinch zoom alone', async () => {
const source = await readFile(path.join(sourceDirectory, 'hooks/useMobileViewport.ts'), 'utf8');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,24 @@ describe('mobile output artifact rendering', () => {
expect(read).toHaveBeenLastCalledWith('preview.png', true);
expect(container.querySelector('img')?.src).toBe('data:image/png;base64,YQ==');
});

it('explains a failed file card and re-reads it on retry', async () => {
const info = vi.fn()
.mockRejectedValueOnce(new Error('Cannot resolve output file: No such file or directory'))
.mockResolvedValue({ name: 'report.zip', size: 2048, mimeType: 'application/zip' });
await act(async () => root.render(
<ArtifactImageReader.Provider value={vi.fn()}>
<MarkdownContent content="[Archive](computer://report.zip)" onGetFileInfo={info} onFileDownload={vi.fn()} />
</ArtifactImageReader.Provider>,
));
expect(container.querySelector('.file-card')?.getAttribute('data-status')).toBe('error');
expect(container.textContent).toContain('chat.fileUnavailable');
expect(container.textContent).toContain('Cannot resolve output file: No such file or directory');
const retry = [...container.querySelectorAll('button')].find(button => button.textContent === 'devices.retry');
expect(retry).toBeDefined();
await act(async () => retry!.click());
expect(info).toHaveBeenCalledTimes(2);
expect(container.querySelector('.file-card')?.getAttribute('data-status')).toBe('ready');
expect(container.textContent).toContain('report.zip');
});
});

This file was deleted.

Loading
Loading