@stigmer/react 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/attachment/AttachmentChipList.d.ts +16 -4
- package/attachment/AttachmentChipList.d.ts.map +1 -1
- package/attachment/AttachmentChipList.js +101 -35
- package/attachment/AttachmentChipList.js.map +1 -1
- package/attachment/AttachmentImageLightbox.d.ts +39 -0
- package/attachment/AttachmentImageLightbox.d.ts.map +1 -0
- package/attachment/AttachmentImageLightbox.js +57 -0
- package/attachment/AttachmentImageLightbox.js.map +1 -0
- package/attachment/useObjectUrl.d.ts +15 -0
- package/attachment/useObjectUrl.d.ts.map +1 -0
- package/attachment/useObjectUrl.js +25 -0
- package/attachment/useObjectUrl.js.map +1 -0
- package/conversation/ConversationMediaAttachment.d.ts +45 -0
- package/conversation/ConversationMediaAttachment.d.ts.map +1 -0
- package/conversation/ConversationMediaAttachment.js +153 -0
- package/conversation/ConversationMediaAttachment.js.map +1 -0
- package/conversation/ConversationTimelineView.d.ts +18 -1
- package/conversation/ConversationTimelineView.d.ts.map +1 -1
- package/conversation/ConversationTimelineView.js +14 -5
- package/conversation/ConversationTimelineView.js.map +1 -1
- package/conversation/ConversationsWorkbench.d.ts.map +1 -1
- package/conversation/ConversationsWorkbench.js +1 -1
- package/conversation/ConversationsWorkbench.js.map +1 -1
- package/conversation/conversationPresentation.d.ts +10 -3
- package/conversation/conversationPresentation.d.ts.map +1 -1
- package/conversation/conversationPresentation.js +10 -3
- package/conversation/conversationPresentation.js.map +1 -1
- package/conversation/index.d.ts +2 -0
- package/conversation/index.d.ts.map +1 -1
- package/conversation/index.js +1 -0
- package/conversation/index.js.map +1 -1
- package/conversation/useConversationMediaUrl.d.ts +65 -0
- package/conversation/useConversationMediaUrl.d.ts.map +1 -0
- package/conversation/useConversationMediaUrl.js +61 -0
- package/conversation/useConversationMediaUrl.js.map +1 -0
- package/execution/MessageAttachments.d.ts +52 -0
- package/execution/MessageAttachments.d.ts.map +1 -0
- package/execution/MessageAttachments.js +142 -0
- package/execution/MessageAttachments.js.map +1 -0
- package/execution/MessageEntry.d.ts +18 -0
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +5 -4
- package/execution/MessageEntry.js.map +1 -1
- package/execution/MessageThread.d.ts +15 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +24 -7
- package/execution/MessageThread.js.map +1 -1
- package/execution/index.d.ts +2 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +1 -0
- package/execution/index.js.map +1 -1
- package/github/decodeGitHubContent.d.ts +10 -5
- package/github/decodeGitHubContent.d.ts.map +1 -1
- package/github/decodeGitHubContent.js +21 -7
- package/github/decodeGitHubContent.js.map +1 -1
- package/github/useGitHubFileReader.d.ts.map +1 -1
- package/github/useGitHubFileReader.js +5 -5
- package/github/useGitHubFileReader.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/internal/form-primitives.d.ts +13 -0
- package/internal/form-primitives.d.ts.map +1 -1
- package/internal/form-primitives.js +13 -0
- package/internal/form-primitives.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +1 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +8 -0
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +12 -2
- package/session/useSessionConversation.js.map +1 -1
- package/src/attachment/AttachmentChipList.tsx +318 -72
- package/src/attachment/AttachmentImageLightbox.tsx +156 -0
- package/src/attachment/__tests__/AttachmentChipList.test.tsx +240 -17
- package/src/attachment/useObjectUrl.ts +28 -0
- package/src/conversation/ConversationMediaAttachment.tsx +308 -0
- package/src/conversation/ConversationTimelineView.tsx +60 -10
- package/src/conversation/ConversationsWorkbench.tsx +2 -0
- package/src/conversation/__tests__/ConversationTimelineView.test.tsx +233 -6
- package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +67 -1
- package/src/conversation/__tests__/useConversationMediaUrl.test.tsx +66 -0
- package/src/conversation/conversationPresentation.ts +10 -3
- package/src/conversation/index.ts +6 -0
- package/src/conversation/useConversationMediaUrl.ts +121 -0
- package/src/execution/MessageAttachments.tsx +364 -0
- package/src/execution/MessageEntry.tsx +38 -0
- package/src/execution/MessageThread.tsx +44 -4
- package/src/execution/__tests__/MessageEntry-attachments.test.tsx +228 -0
- package/src/execution/__tests__/buildThreadItems-attachments.test.ts +174 -0
- package/src/execution/index.ts +6 -0
- package/src/github/__tests__/decodeGitHubContent.test.ts +42 -1
- package/src/github/decodeGitHubContent.ts +22 -5
- package/src/github/useGitHubFileReader.ts +13 -3
- package/src/index.ts +5 -0
- package/src/internal/form-primitives.tsx +14 -0
- package/src/session/SessionViewer.tsx +1 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +62 -0
- package/src/session/useSessionConversation.ts +21 -2
- package/src/workspace/FileViewer.tsx +109 -0
- package/src/workspace/WorkspaceFileReader.ts +55 -1
- package/src/workspace/__tests__/FileViewer.test.tsx +98 -0
- package/src/workspace/index.ts +2 -0
- package/styles.css +1 -1
- package/workspace/FileViewer.d.ts.map +1 -1
- package/workspace/FileViewer.js +40 -2
- package/workspace/FileViewer.js.map +1 -1
- package/workspace/WorkspaceFileReader.d.ts +29 -1
- package/workspace/WorkspaceFileReader.d.ts.map +1 -1
- package/workspace/WorkspaceFileReader.js +42 -0
- package/workspace/WorkspaceFileReader.js.map +1 -1
- package/workspace/index.d.ts +1 -1
- package/workspace/index.d.ts.map +1 -1
- package/workspace/index.js +1 -1
- package/workspace/index.js.map +1 -1
|
@@ -4,6 +4,7 @@ import { useCallback } from "react";
|
|
|
4
4
|
import type { WorkspaceEntry } from "../workspace/useWorkspaceEntries.js";
|
|
5
5
|
import {
|
|
6
6
|
WorkspaceFileNotFoundError,
|
|
7
|
+
workspaceImageMimeType,
|
|
7
8
|
type WorkspaceFileContent,
|
|
8
9
|
type WorkspaceFileReader,
|
|
9
10
|
} from "../workspace/WorkspaceFileReader.js";
|
|
@@ -116,12 +117,16 @@ export function useGitHubFileReader(
|
|
|
116
117
|
|
|
117
118
|
const file = data as GitHubContentsFile;
|
|
118
119
|
if (file.encoding === "base64") {
|
|
119
|
-
return normalizeGitHubContent(
|
|
120
|
+
return normalizeGitHubContent(
|
|
121
|
+
base64ToBytes(file.content),
|
|
122
|
+
file.size,
|
|
123
|
+
workspaceImageMimeType(path),
|
|
124
|
+
);
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
// encoding: "none" is GitHub's signal that the file exceeds the Contents
|
|
123
128
|
// API's ~1 MB cap; the bytes live only behind the Blob API.
|
|
124
|
-
return readViaBlob(parsed, file, token);
|
|
129
|
+
return readViaBlob(parsed, file, path, token);
|
|
125
130
|
},
|
|
126
131
|
[token],
|
|
127
132
|
);
|
|
@@ -138,6 +143,7 @@ export function useGitHubFileReader(
|
|
|
138
143
|
async function readViaBlob(
|
|
139
144
|
parsed: { owner: string; repo: string },
|
|
140
145
|
file: GitHubContentsFile,
|
|
146
|
+
path: string,
|
|
141
147
|
token: string,
|
|
142
148
|
): Promise<WorkspaceFileContent> {
|
|
143
149
|
// Above the ceiling we never download — GitHub already told us the size, so
|
|
@@ -161,5 +167,9 @@ async function readViaBlob(
|
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
const blob: GitHubBlob = await resp.json();
|
|
164
|
-
return normalizeGitHubContent(
|
|
170
|
+
return normalizeGitHubContent(
|
|
171
|
+
base64ToBytes(blob.content),
|
|
172
|
+
blob.size,
|
|
173
|
+
workspaceImageMimeType(path),
|
|
174
|
+
);
|
|
165
175
|
}
|
package/src/index.ts
CHANGED
|
@@ -71,7 +71,9 @@ export {
|
|
|
71
71
|
useWorkspaceContentSearch,
|
|
72
72
|
useWorkspaceSources,
|
|
73
73
|
MAX_WORKSPACE_FILE_READ_BYTES,
|
|
74
|
+
MAX_WORKSPACE_IMAGE_READ_BYTES,
|
|
74
75
|
WorkspaceFileNotFoundError,
|
|
76
|
+
workspaceImageMimeType,
|
|
75
77
|
WorkspaceEditor,
|
|
76
78
|
WorkspaceFileSearch,
|
|
77
79
|
WorkspaceContentSearch,
|
|
@@ -1379,6 +1381,7 @@ export {
|
|
|
1379
1381
|
ConversationComposer,
|
|
1380
1382
|
useConversation,
|
|
1381
1383
|
useConversationList,
|
|
1384
|
+
useConversationMediaUrl,
|
|
1382
1385
|
useConversationTimeline,
|
|
1383
1386
|
useConversationParticipation,
|
|
1384
1387
|
useConversationsWantsHumanCount,
|
|
@@ -1409,6 +1412,8 @@ export type {
|
|
|
1409
1412
|
UseConversationReturn,
|
|
1410
1413
|
UseConversationListOptions,
|
|
1411
1414
|
UseConversationListReturn,
|
|
1415
|
+
UseConversationMediaUrlOptions,
|
|
1416
|
+
UseConversationMediaUrlReturn,
|
|
1412
1417
|
UseConversationTimelineOptions,
|
|
1413
1418
|
UseConversationTimelineReturn,
|
|
1414
1419
|
ConversationCommand,
|
|
@@ -8,6 +8,20 @@ import { cn } from "@stigmer/theme";
|
|
|
8
8
|
// Internal — never exported from the package barrel.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Neutralizes the UA's default `<button>` styling (buttonface background,
|
|
13
|
+
* padding, default cursor). The SDK's scoped preflight in `styles.css`
|
|
14
|
+
* resets only borders/box-sizing; in the console and desktop app the host's
|
|
15
|
+
* global Tailwind preflight covers the rest, but a preflight-less host
|
|
16
|
+
* (docs tours, third-party embeds) would otherwise render every bare button
|
|
17
|
+
* as a gray UA box. Font is NOT reset here — callers that render text set
|
|
18
|
+
* `[font:inherit]` or explicit text utilities themselves.
|
|
19
|
+
*
|
|
20
|
+
* Applies to buttons that are styled entirely by their content (chips,
|
|
21
|
+
* icon-only affordances), not to the themed button components.
|
|
22
|
+
*/
|
|
23
|
+
export const UNSTYLED_BUTTON = "cursor-pointer bg-transparent p-0";
|
|
24
|
+
|
|
11
25
|
/** Standard text-input styling for the operator consoles. */
|
|
12
26
|
export const INPUT_CLASSES = cn(
|
|
13
27
|
"w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
|
|
@@ -795,6 +795,7 @@ const ConversationColumn = memo(function ConversationColumn({
|
|
|
795
795
|
executions={conv.completedExecutions}
|
|
796
796
|
activeStreamExecution={conv.activeStreamExecution}
|
|
797
797
|
pendingUserMessage={conv.pendingUserMessage}
|
|
798
|
+
pendingAttachments={conv.pendingAttachments}
|
|
798
799
|
pendingMessageFailed={!!conv.sendError && !!conv.pendingUserMessage}
|
|
799
800
|
// Observers get a pure transcript: every interaction affordance
|
|
800
801
|
// (retry, approvals, edit, build-from-plan) is opt-in via its
|
|
@@ -260,6 +260,68 @@ describe("useSessionConversation", () => {
|
|
|
260
260
|
expect(result.current.pendingUserMessage).toBe("Hello agent");
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
it("sendFollowUp carries the submit's attachments as pendingAttachments, cleared with the first snapshot", async () => {
|
|
264
|
+
methods.listBySession.mockResolvedValue({ entries: [] });
|
|
265
|
+
const newExec = makeExecution("new-exec", ExecutionPhase.EXECUTION_PENDING);
|
|
266
|
+
newExec.metadata!.id = "new-exec";
|
|
267
|
+
methods.executionCreate.mockResolvedValue(newExec);
|
|
268
|
+
|
|
269
|
+
const newStream = createControllableStream<AgentExecution>();
|
|
270
|
+
methods.subscribe.mockReturnValue(newStream.generator);
|
|
271
|
+
|
|
272
|
+
const attachments = [
|
|
273
|
+
{ filename: "sketch.png", storageKey: "attachments/01AAA/sketch.png", contentType: "image/png" },
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
const { result } = renderHook(
|
|
277
|
+
() => useSessionConversation("session-1", "org"),
|
|
278
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
279
|
+
);
|
|
280
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
281
|
+
|
|
282
|
+
await act(async () => {
|
|
283
|
+
await result.current.sendFollowUp("Here is a sketch", { attachments });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// The optimistic turn carries BOTH facets — text and files (#372).
|
|
287
|
+
expect(result.current.pendingUserMessage).toBe("Here is a sketch");
|
|
288
|
+
expect(result.current.pendingAttachments).toBe(attachments);
|
|
289
|
+
|
|
290
|
+
act(() => {
|
|
291
|
+
newStream.push(
|
|
292
|
+
makeExecution("new-exec", ExecutionPhase.EXECUTION_IN_PROGRESS),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Both clear together once the real execution record takes over.
|
|
297
|
+
await waitFor(() => {
|
|
298
|
+
expect(result.current.pendingUserMessage).toBeNull();
|
|
299
|
+
expect(result.current.pendingAttachments).toBeNull();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("keeps pendingAttachments when the send fails (the chips are what Retry re-sends)", async () => {
|
|
304
|
+
methods.listBySession.mockResolvedValue({ entries: [] });
|
|
305
|
+
methods.executionCreate.mockRejectedValue(new Error("create boom"));
|
|
306
|
+
|
|
307
|
+
const attachments = [
|
|
308
|
+
{ filename: "notes.txt", storageKey: "attachments/01BBB/notes.txt", contentType: "text/plain" },
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
const { result } = renderHook(
|
|
312
|
+
() => useSessionConversation("session-1", "org"),
|
|
313
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
314
|
+
);
|
|
315
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
316
|
+
|
|
317
|
+
await act(async () => {
|
|
318
|
+
await result.current.sendFollowUp("keep my files", { attachments });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(result.current.sendError?.message).toBe("create boom");
|
|
322
|
+
expect(result.current.pendingAttachments).toBe(attachments);
|
|
323
|
+
});
|
|
324
|
+
|
|
263
325
|
it("sendFollowUp creates execution via the SDK", async () => {
|
|
264
326
|
methods.listBySession.mockResolvedValue({ entries: [] });
|
|
265
327
|
const newExec = makeExecution("new-exec", ExecutionPhase.EXECUTION_PENDING);
|
|
@@ -213,6 +213,14 @@ export interface UseSessionConversationReturn {
|
|
|
213
213
|
* {@link sendError} to render the turn as failed with a retry control.
|
|
214
214
|
*/
|
|
215
215
|
readonly pendingUserMessage: string | null;
|
|
216
|
+
/**
|
|
217
|
+
* The attachments submitted with {@link pendingUserMessage}, so the
|
|
218
|
+
* optimistic bubble shows the turn's files, not just its text
|
|
219
|
+
* (stigmer/stigmer#372). Set and cleared in lockstep with the message —
|
|
220
|
+
* including retention on a failed send, where the chips are the evidence
|
|
221
|
+
* of what Retry will re-send. Wire to `MessageThread.pendingAttachments`.
|
|
222
|
+
*/
|
|
223
|
+
readonly pendingAttachments: readonly AttachmentInput[] | null;
|
|
216
224
|
|
|
217
225
|
/** Current workspace entries from the session spec. Empty array when session is not loaded. */
|
|
218
226
|
readonly workspaceEntries: readonly ProtoWorkspaceEntry[];
|
|
@@ -439,6 +447,12 @@ export function useSessionConversation(
|
|
|
439
447
|
const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(
|
|
440
448
|
null,
|
|
441
449
|
);
|
|
450
|
+
// The pending message's attachments, kept in lockstep with the text: one
|
|
451
|
+
// pending turn, two facets. Held separately (not an object) so existing
|
|
452
|
+
// pendingUserMessage consumers keep their string contract.
|
|
453
|
+
const [pendingAttachments, setPendingAttachments] = useState<
|
|
454
|
+
readonly AttachmentInput[] | null
|
|
455
|
+
>(null);
|
|
442
456
|
// Dedicated send-failure state, distinct from the create hook's internal
|
|
443
457
|
// error so it can also cover the session.update() path. The last send's
|
|
444
458
|
// arguments are captured for an exact retry.
|
|
@@ -507,8 +521,9 @@ export function useSessionConversation(
|
|
|
507
521
|
useEffect(() => {
|
|
508
522
|
if (!stream.execution) return;
|
|
509
523
|
if (pendingUserMessage) setPendingUserMessage(null);
|
|
524
|
+
if (pendingAttachments) setPendingAttachments(null);
|
|
510
525
|
if (sendError) setSendError(null);
|
|
511
|
-
}, [pendingUserMessage, sendError, stream.execution]);
|
|
526
|
+
}, [pendingUserMessage, pendingAttachments, sendError, stream.execution]);
|
|
512
527
|
|
|
513
528
|
// Refetch executions when stream reaches a terminal phase so the
|
|
514
529
|
// fetched list reflects the completed status and listActiveId clears.
|
|
@@ -620,6 +635,7 @@ export function useSessionConversation(
|
|
|
620
635
|
// renders the failed-with-retry bubble instead of vanishing.
|
|
621
636
|
if (!options?.buildFromPlan) {
|
|
622
637
|
setPendingUserMessage(message);
|
|
638
|
+
setPendingAttachments(options?.attachments ?? null);
|
|
623
639
|
}
|
|
624
640
|
|
|
625
641
|
try {
|
|
@@ -666,8 +682,10 @@ export function useSessionConversation(
|
|
|
666
682
|
// pendingUserMessage) so the turn renders as failed-with-retry instead
|
|
667
683
|
// of vanishing. Covers both the update() and create() paths. For a
|
|
668
684
|
// build turn this is the FIRST time the message is set — failure is
|
|
669
|
-
// the one case where its label must become visible.
|
|
685
|
+
// the one case where its label must become visible. The attachments
|
|
686
|
+
// ride along: the failed bubble's chips are what Retry will re-send.
|
|
670
687
|
setPendingUserMessage(message);
|
|
688
|
+
setPendingAttachments(options?.attachments ?? null);
|
|
671
689
|
setSendError(toError(err));
|
|
672
690
|
if (process.env.NODE_ENV !== "production") {
|
|
673
691
|
console.error("[useSessionConversation] sendFollowUp failed:", err);
|
|
@@ -753,6 +771,7 @@ export function useSessionConversation(
|
|
|
753
771
|
retryLastSend,
|
|
754
772
|
|
|
755
773
|
pendingUserMessage,
|
|
774
|
+
pendingAttachments,
|
|
756
775
|
|
|
757
776
|
workspaceEntries,
|
|
758
777
|
mcpServerUsages,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
useCallback,
|
|
6
6
|
useEffect,
|
|
7
7
|
useImperativeHandle,
|
|
8
|
+
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
useState,
|
|
10
11
|
type KeyboardEvent,
|
|
@@ -15,15 +16,19 @@ import {
|
|
|
15
16
|
FileChangeCaptureLevel,
|
|
16
17
|
FileChangeType,
|
|
17
18
|
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
19
|
+
import { AttachmentImageLightbox } from "../attachment/AttachmentImageLightbox.js";
|
|
20
|
+
import { useObjectUrl } from "../attachment/useObjectUrl.js";
|
|
18
21
|
import { ArtifactContentRenderer } from "../execution/ArtifactContentRenderer.js";
|
|
19
22
|
import { FileChangeDiff } from "../execution/FileChangesView.js";
|
|
20
23
|
import { useFileChangeContent } from "../execution/useFileChangeContent.js";
|
|
24
|
+
import { UNSTYLED_BUTTON } from "../internal/form-primitives.js";
|
|
21
25
|
import type { RevealTarget } from "../internal/useRevealLine.js";
|
|
22
26
|
import type { SelectedWorkspaceFile } from "../internal/store/workspace-file-selection-store.js";
|
|
23
27
|
import { useWorkspaceFileContent } from "./useWorkspaceFileContent.js";
|
|
24
28
|
import type { WorkspaceEntry } from "./useWorkspaceEntries.js";
|
|
25
29
|
import {
|
|
26
30
|
WorkspaceFileNotFoundError,
|
|
31
|
+
workspaceImageMimeType,
|
|
27
32
|
type WorkspaceFileReader,
|
|
28
33
|
} from "./WorkspaceFileReader.js";
|
|
29
34
|
|
|
@@ -431,6 +436,23 @@ function FileViewerBody({
|
|
|
431
436
|
return <MessageState>Content not available for preview.</MessageState>;
|
|
432
437
|
}
|
|
433
438
|
|
|
439
|
+
// A binary that is a renderable image, delivered whole by the reader,
|
|
440
|
+
// shows the picture instead of the binary notice (stigmer/stigmer#379).
|
|
441
|
+
// The MIME gate re-derives from the path: bytes without a recognized image
|
|
442
|
+
// extension still fall through to the notice.
|
|
443
|
+
const imageMime = workspaceImageMimeType(basename);
|
|
444
|
+
if (content.bytes && imageMime) {
|
|
445
|
+
return (
|
|
446
|
+
<ImageFileContent
|
|
447
|
+
bytes={content.bytes}
|
|
448
|
+
mime={imageMime}
|
|
449
|
+
basename={basename}
|
|
450
|
+
size={content.size}
|
|
451
|
+
change={change}
|
|
452
|
+
/>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
434
456
|
if (content.isBinary) {
|
|
435
457
|
return (
|
|
436
458
|
<MessageState>
|
|
@@ -469,6 +491,93 @@ function FileViewerBody({
|
|
|
469
491
|
);
|
|
470
492
|
}
|
|
471
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Inline image rendering for a workspace file whose reader delivered whole
|
|
496
|
+
* image bytes (stigmer/stigmer#379). The bytes become an object URL for the
|
|
497
|
+
* component's lifetime; clicking the image opens the shared
|
|
498
|
+
* {@link AttachmentImageLightbox} at full size — the same viewer attachment
|
|
499
|
+
* chips and conversation media use, so pictures behave identically across
|
|
500
|
+
* surfaces. If the browser cannot decode the bytes (a corrupt file behind an
|
|
501
|
+
* image extension), the component degrades to the binary notice — never a
|
|
502
|
+
* broken-image glyph.
|
|
503
|
+
*/
|
|
504
|
+
function ImageFileContent({
|
|
505
|
+
bytes,
|
|
506
|
+
mime,
|
|
507
|
+
basename,
|
|
508
|
+
size,
|
|
509
|
+
change,
|
|
510
|
+
}: {
|
|
511
|
+
readonly bytes: Uint8Array;
|
|
512
|
+
readonly mime: string;
|
|
513
|
+
readonly basename: string;
|
|
514
|
+
readonly size: number;
|
|
515
|
+
readonly change?: FileChange;
|
|
516
|
+
}) {
|
|
517
|
+
// Memo (not effect) is correct for the Blob — it holds no OS resource; the
|
|
518
|
+
// object URL over it is effect-managed inside useObjectUrl.
|
|
519
|
+
const blob = useMemo(() => new Blob([bytes as BlobPart], { type: mime }), [bytes, mime]);
|
|
520
|
+
const url = useObjectUrl(blob);
|
|
521
|
+
const [loadFailed, setLoadFailed] = useState(false);
|
|
522
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
523
|
+
|
|
524
|
+
if (loadFailed) {
|
|
525
|
+
return (
|
|
526
|
+
<MessageState>
|
|
527
|
+
Binary file — not shown{size ? ` (${formatBytes(size)})` : ""}.
|
|
528
|
+
</MessageState>
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return (
|
|
533
|
+
<>
|
|
534
|
+
{change && (
|
|
535
|
+
<p className="border-b border-border-muted px-4 py-1.5 text-[0.65rem] text-muted-foreground">
|
|
536
|
+
Live — may differ from the reviewed change.
|
|
537
|
+
</p>
|
|
538
|
+
)}
|
|
539
|
+
<div className="flex flex-col items-center gap-2 p-4">
|
|
540
|
+
{url ? (
|
|
541
|
+
// UNSTYLED_BUTTON is load-bearing: without it the button shows the
|
|
542
|
+
// UA's default box in preflight-less hosts (see the constant).
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
onClick={() => setLightboxOpen(true)}
|
|
546
|
+
aria-label={`Preview ${basename} at full size`}
|
|
547
|
+
className={cn(
|
|
548
|
+
UNSTYLED_BUTTON,
|
|
549
|
+
"max-w-full rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
550
|
+
)}
|
|
551
|
+
>
|
|
552
|
+
<img
|
|
553
|
+
src={url}
|
|
554
|
+
alt={basename}
|
|
555
|
+
onError={() => setLoadFailed(true)}
|
|
556
|
+
className="max-h-[70vh] max-w-full rounded-md border border-border object-contain"
|
|
557
|
+
/>
|
|
558
|
+
</button>
|
|
559
|
+
) : (
|
|
560
|
+
// One-frame placeholder until the object-URL effect runs.
|
|
561
|
+
<div
|
|
562
|
+
className="h-48 w-72 max-w-full animate-pulse rounded-md bg-muted"
|
|
563
|
+
aria-hidden="true"
|
|
564
|
+
/>
|
|
565
|
+
)}
|
|
566
|
+
<p className="text-[0.65rem] tabular-nums text-muted-foreground">
|
|
567
|
+
{mime}
|
|
568
|
+
{size ? ` · ${formatBytes(size)}` : ""}
|
|
569
|
+
</p>
|
|
570
|
+
</div>
|
|
571
|
+
<AttachmentImageLightbox
|
|
572
|
+
src={url}
|
|
573
|
+
filename={basename}
|
|
574
|
+
open={lightboxOpen}
|
|
575
|
+
onClose={() => setLightboxOpen(false)}
|
|
576
|
+
/>
|
|
577
|
+
</>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
472
581
|
/**
|
|
473
582
|
* The captured-content fallback: renders a change's after-side text (the
|
|
474
583
|
* bytes the session already holds — inline or offloaded to artifact storage)
|
|
@@ -12,12 +12,57 @@ import type { WorkspaceEntry } from "./useWorkspaceEntries.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export const MAX_WORKSPACE_FILE_READ_BYTES = 1_048_576; // 1 MiB
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Byte ceiling for delivering a workspace *image* in full (stigmer/stigmer#379).
|
|
17
|
+
*
|
|
18
|
+
* Deliberately larger than {@link MAX_WORKSPACE_FILE_READ_BYTES}: text
|
|
19
|
+
* degrades gracefully when truncated, an image does not — a partial PNG is
|
|
20
|
+
* useless, so readers deliver image bytes whole or not at all. 10 MiB matches
|
|
21
|
+
* the GitHub reader's existing blob-fetch ceiling; the desktop native reader
|
|
22
|
+
* mirrors it as `MAX_IMAGE_READ_BYTES` in `src-tauri/src/workspace.rs`.
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_WORKSPACE_IMAGE_READ_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Raster image formats the viewer renders inline, keyed by file extension.
|
|
28
|
+
*
|
|
29
|
+
* SVG is deliberately absent: it decodes as UTF-8 and flows through the text
|
|
30
|
+
* path, where a workspace browser wants it — it is source to inspect, not a
|
|
31
|
+
* picture (matching editor convention). The desktop native reader mirrors
|
|
32
|
+
* this list as `IMAGE_EXTENSIONS` in `src-tauri/src/workspace.rs`.
|
|
33
|
+
*/
|
|
34
|
+
const IMAGE_MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
35
|
+
png: "image/png",
|
|
36
|
+
jpg: "image/jpeg",
|
|
37
|
+
jpeg: "image/jpeg",
|
|
38
|
+
gif: "image/gif",
|
|
39
|
+
webp: "image/webp",
|
|
40
|
+
avif: "image/avif",
|
|
41
|
+
bmp: "image/bmp",
|
|
42
|
+
ico: "image/x-icon",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* MIME type for a workspace path the viewer can render as an inline image,
|
|
47
|
+
* or `null` for everything else. The single policy point shared by readers
|
|
48
|
+
* (deciding whether to attach {@link WorkspaceFileContent.bytes}) and the
|
|
49
|
+
* viewer (deciding whether to render an image arm) — extension-based, since
|
|
50
|
+
* workspace reads have no server-provided content type.
|
|
51
|
+
*/
|
|
52
|
+
export function workspaceImageMimeType(path: string): string | null {
|
|
53
|
+
const dot = path.lastIndexOf(".");
|
|
54
|
+
if (dot === -1) return null;
|
|
55
|
+
const ext = path.slice(dot + 1).toLowerCase();
|
|
56
|
+
return IMAGE_MIME_BY_EXTENSION[ext] ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
15
59
|
/**
|
|
16
60
|
* Decoded content (plus metadata) for a single workspace file.
|
|
17
61
|
*
|
|
18
62
|
* `text` is `null` whenever the bytes are not displayable as UTF-8 — either
|
|
19
63
|
* binary (`isBinary`) or an undecodable byte sequence (`encoding: "unknown"`).
|
|
20
|
-
* The renderer uses `isBinary`/`text === null` for its fallback state
|
|
64
|
+
* The renderer uses `isBinary`/`text === null` for its fallback state, except
|
|
65
|
+
* when `bytes` carries a renderable image.
|
|
21
66
|
*/
|
|
22
67
|
export interface WorkspaceFileContent {
|
|
23
68
|
/** Decoded UTF-8 text, or `null` when binary or undecodable. */
|
|
@@ -30,6 +75,15 @@ export interface WorkspaceFileContent {
|
|
|
30
75
|
readonly encoding: "utf-8" | "base64" | "none" | "unknown";
|
|
31
76
|
/** `true` when `text` holds only the first {@link MAX_WORKSPACE_FILE_READ_BYTES} bytes. */
|
|
32
77
|
readonly truncated?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* The complete raw bytes, populated **only** for binary files that are
|
|
80
|
+
* renderable images ({@link workspaceImageMimeType}) within
|
|
81
|
+
* {@link MAX_WORKSPACE_IMAGE_READ_BYTES} — never a channel for arbitrary
|
|
82
|
+
* binaries, and never partial (a truncated image cannot render). Optional
|
|
83
|
+
* and additive: readers that predate it remain valid, and the viewer falls
|
|
84
|
+
* back to the binary notice when it is absent (stigmer/stigmer#379).
|
|
85
|
+
*/
|
|
86
|
+
readonly bytes?: Uint8Array;
|
|
33
87
|
}
|
|
34
88
|
|
|
35
89
|
/**
|
|
@@ -159,6 +159,104 @@ describe("FileViewer", () => {
|
|
|
159
159
|
);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
// ---------------------------------------------------------------------
|
|
163
|
+
// Image arm (stigmer/stigmer#379) — bytes delivered whole by the reader
|
|
164
|
+
// render as a picture; every gate failure falls back to the binary notice.
|
|
165
|
+
// ---------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]);
|
|
168
|
+
|
|
169
|
+
function imageContent(): WorkspaceFileContent {
|
|
170
|
+
return {
|
|
171
|
+
text: null,
|
|
172
|
+
isBinary: true,
|
|
173
|
+
size: PNG_BYTES.length,
|
|
174
|
+
encoding: "base64",
|
|
175
|
+
bytes: PNG_BYTES,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
it("renders an image from delivered bytes instead of the binary notice", async () => {
|
|
180
|
+
render(
|
|
181
|
+
<FileViewer
|
|
182
|
+
selectedFile={{ entryId: "e1", path: "assets/logo.png" }}
|
|
183
|
+
entries={ENTRIES}
|
|
184
|
+
reader={readerFor(imageContent())}
|
|
185
|
+
/>,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
const img = document.querySelector("img");
|
|
190
|
+
expect(img).toBeTruthy();
|
|
191
|
+
expect(img!.getAttribute("src")).toContain("blob:");
|
|
192
|
+
expect(img!.getAttribute("alt")).toBe("logo.png");
|
|
193
|
+
});
|
|
194
|
+
expect(screen.queryByText(/Binary file/i)).toBeNull();
|
|
195
|
+
// The click-to-open affordance is present and named.
|
|
196
|
+
expect(
|
|
197
|
+
screen.getByRole("button", { name: "Preview logo.png at full size" }),
|
|
198
|
+
).toBeTruthy();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("opens the shared lightbox at full size when the image is clicked", async () => {
|
|
202
|
+
// happy-dom does not implement the native dialog show/close methods.
|
|
203
|
+
HTMLDialogElement.prototype.showModal = function showModal() {
|
|
204
|
+
this.open = true;
|
|
205
|
+
};
|
|
206
|
+
HTMLDialogElement.prototype.close = function close() {
|
|
207
|
+
this.open = false;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
render(
|
|
211
|
+
<FileViewer
|
|
212
|
+
selectedFile={{ entryId: "e1", path: "assets/logo.png" }}
|
|
213
|
+
entries={ENTRIES}
|
|
214
|
+
reader={readerFor(imageContent())}
|
|
215
|
+
/>,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await waitFor(() => expect(document.querySelector("img")).toBeTruthy());
|
|
219
|
+
fireEvent.click(
|
|
220
|
+
screen.getByRole("button", { name: "Preview logo.png at full size" }),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const dialog = document.querySelector("dialog");
|
|
224
|
+
expect(dialog).toBeTruthy();
|
|
225
|
+
expect(dialog!.getAttribute("aria-label")).toBe("Preview logo.png");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("keeps the binary notice when bytes arrive for a non-image path", async () => {
|
|
229
|
+
// The viewer re-derives the MIME gate from the path: bytes on something
|
|
230
|
+
// without a recognized image extension never render as a picture.
|
|
231
|
+
render(
|
|
232
|
+
<FileViewer
|
|
233
|
+
selectedFile={{ entryId: "e1", path: "data.bin" }}
|
|
234
|
+
entries={ENTRIES}
|
|
235
|
+
reader={readerFor(imageContent())}
|
|
236
|
+
/>,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
await waitFor(() => expect(screen.getByText(/Binary file/i)).toBeTruthy());
|
|
240
|
+
expect(document.querySelector("img")).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("degrades to the binary notice when the browser cannot decode the bytes", async () => {
|
|
244
|
+
render(
|
|
245
|
+
<FileViewer
|
|
246
|
+
selectedFile={{ entryId: "e1", path: "assets/logo.png" }}
|
|
247
|
+
entries={ENTRIES}
|
|
248
|
+
reader={readerFor(imageContent())}
|
|
249
|
+
/>,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
await waitFor(() => expect(document.querySelector("img")).toBeTruthy());
|
|
253
|
+
fireEvent.error(document.querySelector("img")!);
|
|
254
|
+
|
|
255
|
+
// Never a broken-image glyph — the honest binary state instead.
|
|
256
|
+
await waitFor(() => expect(screen.getByText(/Binary file/i)).toBeTruthy());
|
|
257
|
+
expect(document.querySelector("img")).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
|
|
162
260
|
it("shows the error state with a working Retry", async () => {
|
|
163
261
|
let attempt = 0;
|
|
164
262
|
const reader: WorkspaceFileReader = vi.fn(async () => {
|
package/src/workspace/index.ts
CHANGED
|
@@ -9,7 +9,9 @@ export type {
|
|
|
9
9
|
} from "./WorkspaceFileLister.js";
|
|
10
10
|
export {
|
|
11
11
|
MAX_WORKSPACE_FILE_READ_BYTES,
|
|
12
|
+
MAX_WORKSPACE_IMAGE_READ_BYTES,
|
|
12
13
|
WorkspaceFileNotFoundError,
|
|
14
|
+
workspaceImageMimeType,
|
|
13
15
|
} from "./WorkspaceFileReader.js";
|
|
14
16
|
export type {
|
|
15
17
|
WorkspaceFileContent,
|