Fix structured attachment rendering (#69)
* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering
This commit is contained in:
38
apps/dashboard/src/RoomAttachmentGallery.tsx
Normal file
38
apps/dashboard/src/RoomAttachmentGallery.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { RoomThreadEntry } from './roomThread';
|
||||
|
||||
type RoomAttachment = NonNullable<RoomThreadEntry['attachments']>[number];
|
||||
|
||||
function attachmentDisplayName(attachment: RoomAttachment): string {
|
||||
if (attachment.name) return attachment.name;
|
||||
const segments = attachment.path.split(/[\\/]/);
|
||||
return segments.at(-1) || 'attachment';
|
||||
}
|
||||
|
||||
function attachmentUrl(attachment: RoomAttachment): string {
|
||||
return `/api/attachments?path=${encodeURIComponent(attachment.path)}`;
|
||||
}
|
||||
|
||||
export function RoomAttachmentGallery({
|
||||
attachments,
|
||||
}: {
|
||||
attachments?: RoomThreadEntry['attachments'];
|
||||
}) {
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="room-attachments">
|
||||
{attachments.map((attachment) => {
|
||||
const name = attachmentDisplayName(attachment);
|
||||
const url = attachmentUrl(attachment);
|
||||
return (
|
||||
<li className="room-attachment" key={`${attachment.path}:${name}`}>
|
||||
<a href={url} rel="noreferrer" target="_blank">
|
||||
<img alt={name} loading="lazy" src={url} />
|
||||
<span>{name}</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -161,4 +161,51 @@ describe('RoomCardV2', () => {
|
||||
expect(html).toContain('<code>pnpm openapi:sync</code>');
|
||||
expect(html).not.toContain('`origin/dev`');
|
||||
});
|
||||
|
||||
it('renders message attachments as dashboard images', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RoomCardV2, {
|
||||
activity: activity({
|
||||
messages: [
|
||||
{
|
||||
id: 'bot-attachment',
|
||||
sender: 'bot-1',
|
||||
senderName: 'owner',
|
||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
timestamp: '2026-04-28T02:00:00.000Z',
|
||||
isFromMe: false,
|
||||
isBotMessage: true,
|
||||
sourceKind: 'bot',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
activityLoading: false,
|
||||
busy: false,
|
||||
draft: '',
|
||||
entry,
|
||||
expanded: true,
|
||||
inboxItems: [],
|
||||
locale: 'ko',
|
||||
onDraftChange: () => {},
|
||||
onSendMessage: () => {},
|
||||
onToggle: () => {},
|
||||
pinned: true,
|
||||
t,
|
||||
...formatters,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('class="room-attachments"');
|
||||
expect(html).toContain(
|
||||
'/api/attachments?path=%2Ftmp%2Fbar-chart-label-fit-playwright.png',
|
||||
);
|
||||
expect(html).toContain('bar-chart-label-fit-playwright.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Send } from 'lucide-react';
|
||||
import type { DashboardOverview, DashboardRoomActivity } from './api';
|
||||
import type { Locale, Messages } from './i18n';
|
||||
import { ParsedBody } from './ParsedBody';
|
||||
import { RoomAttachmentGallery } from './RoomAttachmentGallery';
|
||||
import {
|
||||
buildRoomThreadEntries,
|
||||
isWatcherRoomMessage,
|
||||
@@ -779,6 +780,7 @@ function RoomTimelineEntry({
|
||||
</header>
|
||||
<div className="room-timeline-body">
|
||||
<ParsedBody text={entry.content} />
|
||||
<RoomAttachmentGallery attachments={entry.attachments} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,11 @@ export interface DashboardRoomActivity {
|
||||
sender: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
attachments?: Array<{
|
||||
path: string;
|
||||
name?: string;
|
||||
mime?: string;
|
||||
}>;
|
||||
timestamp: string;
|
||||
isFromMe: boolean;
|
||||
isBotMessage: boolean;
|
||||
@@ -141,6 +146,11 @@ export interface DashboardRoomActivity {
|
||||
verdict: string | null;
|
||||
createdAt: string;
|
||||
outputText: string;
|
||||
attachments?: Array<{
|
||||
path: string;
|
||||
name?: string;
|
||||
mime?: string;
|
||||
}>;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type RoomThreadEntry = {
|
||||
id: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
attachments?: RoomMessage['attachments'];
|
||||
timestamp: string;
|
||||
isFromMe: boolean;
|
||||
isBotMessage: boolean;
|
||||
@@ -51,6 +52,7 @@ function toMessageEntry(message: RoomMessage): RoomThreadEntry {
|
||||
id: message.id,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
timestamp: message.timestamp,
|
||||
isFromMe: message.isFromMe,
|
||||
isBotMessage: message.isBotMessage,
|
||||
@@ -64,6 +66,7 @@ function toOutputEntry(output: RoomOutput): RoomThreadEntry | null {
|
||||
id: `out:${output.id}`,
|
||||
senderName: output.role,
|
||||
content: output.outputText,
|
||||
attachments: output.attachments,
|
||||
timestamp: output.createdAt,
|
||||
isFromMe: false,
|
||||
isBotMessage: true,
|
||||
|
||||
@@ -4039,6 +4039,38 @@ progress::-moz-progress-bar {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.room-attachments {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.room-attachment a {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: min(220px, 100%);
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.room-attachment img {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.room-attachment span {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
color: var(--muted-soft);
|
||||
}
|
||||
|
||||
/* thread — flat */
|
||||
.room-thread {
|
||||
list-style: none;
|
||||
|
||||
Reference in New Issue
Block a user