Skip to content

Commit 23be83e

Browse files
committed
chore: merge upstream/main (resolve legacy_aliases conflict, keep upstream fix)
Upstream PR tinyhumansai#2865 also fixed the unquoted-identifier key issue in parse_frontend_legacy_aliases (inline logic, also skips comment lines in the compact join). Resolve conflict by keeping the upstream approach and dropping the object_key() helper introduced in our branch.
2 parents f14d856 + 29aeba8 commit 23be83e

56 files changed

Lines changed: 2798 additions & 204 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/chat/chatSendError.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export type ChatSendErrorCode =
1515
| 'safety_timeout'
1616
| 'usage_limit_reached'
1717
| 'prompt_blocked'
18-
| 'prompt_review';
18+
| 'prompt_review'
19+
| 'attachment_invalid';
1920

2021
export interface ChatSendError {
2122
code: ChatSendErrorCode;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { type Attachment, formatFileSize } from '../../lib/attachments';
2+
import { useT } from '../../lib/i18n/I18nContext';
3+
4+
interface AttachmentPreviewProps {
5+
attachments: Attachment[];
6+
onRemove: (id: string) => void;
7+
disabled?: boolean;
8+
}
9+
10+
export default function AttachmentPreview({
11+
attachments,
12+
onRemove,
13+
disabled,
14+
}: AttachmentPreviewProps) {
15+
const { t } = useT();
16+
17+
if (attachments.length === 0) return null;
18+
19+
return (
20+
<div className="flex flex-wrap gap-2 px-1 pb-1">
21+
{attachments.map(attachment => (
22+
<div
23+
key={attachment.id}
24+
className="relative flex items-center gap-2 rounded-lg border border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 px-2 py-1.5 text-xs text-stone-700 dark:text-neutral-300 max-w-[180px]">
25+
<img
26+
src={attachment.dataUri}
27+
alt={attachment.file.name}
28+
className="w-8 h-8 rounded object-cover flex-shrink-0"
29+
/>
30+
<div className="flex flex-col min-w-0">
31+
<span className="truncate font-medium leading-tight">{attachment.file.name}</span>
32+
<span className="text-stone-400 dark:text-neutral-500 leading-tight">
33+
{formatFileSize(attachment.file.size)}
34+
</span>
35+
</div>
36+
<button
37+
type="button"
38+
aria-label={t('chat.attachment.remove').replace('{name}', attachment.file.name)}
39+
onClick={() => onRemove(attachment.id)}
40+
disabled={disabled}
41+
className="absolute -top-1.5 -right-1.5 w-4 h-4 flex items-center justify-center rounded-full bg-stone-400 dark:bg-neutral-600 text-white hover:bg-stone-600 dark:hover:bg-neutral-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0">
42+
<svg className="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
43+
<path
44+
strokeLinecap="round"
45+
strokeLinejoin="round"
46+
strokeWidth={3}
47+
d="M6 18L18 6M6 6l12 12"
48+
/>
49+
</svg>
50+
</button>
51+
</div>
52+
))}
53+
</div>
54+
);
55+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import type { Attachment } from '../../../lib/attachments';
5+
import AttachmentPreview from '../AttachmentPreview';
6+
7+
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
8+
const blob = new Blob([new Uint8Array(512)], { type: 'image/png' });
9+
return {
10+
id: 'att-1',
11+
file: new File([blob], 'test.png', { type: 'image/png' }),
12+
dataUri: 'data:image/png;base64,abc',
13+
mimeType: 'image/png',
14+
...overrides,
15+
};
16+
}
17+
18+
describe('AttachmentPreview', () => {
19+
it('renders nothing when attachments list is empty', () => {
20+
const { container } = render(<AttachmentPreview attachments={[]} onRemove={vi.fn()} />);
21+
expect(container.firstChild).toBeNull();
22+
});
23+
24+
it('renders a chip with filename and file size for each attachment', () => {
25+
const att = makeAttachment();
26+
render(<AttachmentPreview attachments={[att]} onRemove={vi.fn()} />);
27+
expect(screen.getByText('test.png')).toBeInTheDocument();
28+
expect(screen.getByText('512 B')).toBeInTheDocument();
29+
});
30+
31+
it('renders a thumbnail image with the dataUri as src', () => {
32+
const att = makeAttachment({ dataUri: 'data:image/png;base64,xyz' });
33+
render(<AttachmentPreview attachments={[att]} onRemove={vi.fn()} />);
34+
const img = screen.getByAltText('test.png') as HTMLImageElement;
35+
expect(img.src).toBe('data:image/png;base64,xyz');
36+
});
37+
38+
it('calls onRemove with the attachment id when × is clicked', () => {
39+
const onRemove = vi.fn();
40+
const att = makeAttachment({ id: 'att-42' });
41+
render(<AttachmentPreview attachments={[att]} onRemove={onRemove} />);
42+
fireEvent.click(screen.getByRole('button', { name: /remove test\.png/i }));
43+
expect(onRemove).toHaveBeenCalledWith('att-42');
44+
});
45+
46+
it('disables the remove button when disabled prop is true', () => {
47+
const att = makeAttachment();
48+
render(<AttachmentPreview attachments={[att]} onRemove={vi.fn()} disabled />);
49+
expect(screen.getByRole('button', { name: /remove test\.png/i })).toBeDisabled();
50+
});
51+
52+
it('renders multiple chips', () => {
53+
const a1 = makeAttachment({ id: '1', file: new File([], 'a.png', { type: 'image/png' }) });
54+
const a2 = makeAttachment({
55+
id: '2',
56+
file: new File([], 'b.jpg', { type: 'image/jpeg' }),
57+
mimeType: 'image/jpeg',
58+
});
59+
render(<AttachmentPreview attachments={[a1, a2]} onRemove={vi.fn()} />);
60+
expect(screen.getByText('a.png')).toBeInTheDocument();
61+
expect(screen.getByText('b.jpg')).toBeInTheDocument();
62+
});
63+
});

app/src/components/settings/panels/AIPanel.tsx

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -914,24 +914,32 @@ const MetricTile = ({
914914
value: string;
915915
detail?: string;
916916
}) => (
917-
<div className="rounded-md bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
918-
<div className="text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
917+
<div className="min-w-0 overflow-hidden rounded-md bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
918+
<div className="truncate text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
919919
{label}
920920
</div>
921-
<div className="mt-1 text-sm font-semibold text-stone-900 dark:text-neutral-100">{value}</div>
921+
<div className="mt-1 truncate text-sm font-semibold text-stone-900 dark:text-neutral-100">
922+
{value}
923+
</div>
922924
{detail ? (
923-
<div className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">{detail}</div>
925+
<div className="mt-0.5 truncate text-[11px] text-stone-500 dark:text-neutral-400">
926+
{detail}
927+
</div>
924928
) : null}
925929
</div>
926930
);
927931

928932
const FormulaRow = ({ label, value, detail }: { label: string; value: string; detail: string }) => (
929-
<div className="rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
933+
<div className="min-w-0 overflow-hidden rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2">
930934
<div className="flex items-center justify-between gap-3">
931-
<span className="text-xs font-medium text-stone-800 dark:text-neutral-100">{label}</span>
932-
<span className="font-mono text-xs text-stone-600 dark:text-neutral-300">{value}</span>
935+
<span className="min-w-0 truncate text-xs font-medium text-stone-800 dark:text-neutral-100">
936+
{label}
937+
</span>
938+
<span className="shrink-0 font-mono text-xs text-stone-600 dark:text-neutral-300">
939+
{value}
940+
</span>
933941
</div>
934-
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">{detail}</div>
942+
<div className="mt-1 truncate text-[11px] text-stone-500 dark:text-neutral-400">{detail}</div>
935943
</div>
936944
);
937945

@@ -1167,7 +1175,7 @@ export const BackgroundLoopControls = ({
11671175
const showHeartbeat = view === 'all' || view === 'heartbeat';
11681176
const showLedger = view === 'all' || view === 'ledger';
11691177
const gridCols =
1170-
view === 'all' ? 'lg:grid-cols-[minmax(0,1fr)_minmax(300px,0.8fr)]' : 'lg:grid-cols-1';
1178+
view === 'all' ? 'md:grid-cols-[minmax(0,1fr)_minmax(260px,0.8fr)]' : 'grid-cols-1';
11711179

11721180
return (
11731181
<div className="space-y-4">
@@ -1237,9 +1245,9 @@ export const BackgroundLoopControls = ({
12371245
void applyHeartbeatPatch({ notify_meetings: !settings.notify_meetings })
12381246
}
12391247
/>
1240-
<div className="grid gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 md:grid-cols-3">
1241-
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
1242-
<span>{t('settings.ai.calendarCap')}</span>
1248+
<div className="grid gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 sm:grid-cols-3">
1249+
<label className="min-w-0 space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
1250+
<span className="whitespace-nowrap">{t('settings.ai.calendarCap')}</span>
12431251
<select
12441252
value={maxCalendarConnectionsPerTick}
12451253
disabled={saving === 'max_calendar_connections_per_tick'}
@@ -1256,8 +1264,8 @@ export const BackgroundLoopControls = ({
12561264
))}
12571265
</select>
12581266
</label>
1259-
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
1260-
<span>{t('settings.ai.meetingLookahead')}</span>
1267+
<label className="min-w-0 space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
1268+
<span className="whitespace-nowrap">{t('settings.ai.meetingLookahead')}</span>
12611269
<select
12621270
value={settings.meeting_lookahead_minutes}
12631271
disabled={saving === 'meeting_lookahead_minutes'}
@@ -1274,8 +1282,10 @@ export const BackgroundLoopControls = ({
12741282
))}
12751283
</select>
12761284
</label>
1277-
<label className="space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
1278-
<span>{t('settings.ai.reminderLookahead')}</span>
1285+
<label className="min-w-0 space-y-1 text-xs font-medium text-stone-700 dark:text-neutral-200">
1286+
<span className="whitespace-nowrap">
1287+
{t('settings.ai.reminderLookahead')}
1288+
</span>
12791289
<select
12801290
value={settings.reminder_lookahead_minutes}
12811291
disabled={saving === 'reminder_lookahead_minutes'}
@@ -1379,16 +1389,16 @@ export const BackgroundLoopControls = ({
13791389
<div className="divide-y divide-stone-200 dark:divide-neutral-800">
13801390
{loops.map(loop => (
13811391
<div key={loop.name} className="grid gap-2 px-3 py-3 md:grid-cols-[150px_1fr]">
1382-
<div>
1383-
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
1392+
<div className="min-w-0">
1393+
<div className="truncate text-sm font-medium text-stone-900 dark:text-neutral-100">
13841394
{loop.name}
13851395
</div>
13861396
<div className="mt-0.5 flex flex-wrap gap-1 text-[11px] text-stone-500 dark:text-neutral-400">
13871397
<span>{loop.enabled ? t('settings.ai.on') : t('settings.ai.off')}</span>
13881398
<span>{loop.cadence}</span>
13891399
</div>
13901400
</div>
1391-
<div className="text-xs text-stone-600 dark:text-neutral-300">
1401+
<div className="min-w-0 text-xs text-stone-600 dark:text-neutral-300">
13921402
<div>{loop.work}</div>
13931403
<div className="mt-1 font-mono text-[11px] text-stone-500 dark:text-neutral-400">
13941404
{t('settings.ai.routeLabel').replace('{route}', loop.route)}

0 commit comments

Comments
 (0)