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:
Eyejoker
2026-04-28 17:36:11 +09:00
committed by GitHub
parent 96d99f63e0
commit 6197e90c8c
18 changed files with 837 additions and 104 deletions

View 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>
);
}

View File

@@ -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');
});
});

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;