Skip to content
Open
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
1 change: 1 addition & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1961,6 +1961,7 @@
"sessions.imageStoredAsLocalFile": "Image upload is offline; added as a pending file attachment.",
"sessions.fileDownload": "Download",
"sessions.fileDownloadFull": "Download full file",
"sessions.openLivePreview": "Open live preview",
"sessions.filePreviewRendered": "Rendered",
"sessions.filePreviewRaw": "Raw",
"sessions.filePreviewTruncated": "Preview shows only the start of this file. Download the full file to see everything.",
Expand Down
1 change: 1 addition & 0 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1961,6 +1961,7 @@
"sessions.imageStoredAsLocalFile": "图片上传处于离线状态,已作为待同步文件附件添加。",
"sessions.fileDownload": "下载",
"sessions.fileDownloadFull": "下载完整文件",
"sessions.openLivePreview": "打开实时预览",
"sessions.filePreviewRendered": "渲染",
"sessions.filePreviewRaw": "原文",
"sessions.filePreviewTruncated": "预览仅显示文件开头部分。下载完整文件以查看全部内容。",
Expand Down
4 changes: 4 additions & 0 deletions packages/components/src/components/ai-gui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface SessionChatStreamProps {
onFilePathClick?: (filePath: string) => void;
/** Routes HTML attachment clicks to a live file or Browser surface. */
onOpenHtmlFile?: (file: SessionFilePayload) => boolean;
/** Whether an HTML attachment can be routed to a live file or Browser surface. */
canOpenHtmlFile?: (file: SessionFilePayload) => boolean;
messageFileDiffEntriesByTurn?: MessageFileDiffEntriesByTurn;
assistantActions?: AssistantMessageAction[];
assistantActionsMessageId?: string | null;
Expand Down Expand Up @@ -171,6 +173,7 @@ const SessionChatStreamImpl = forwardRef<SessionChatStreamHandle, SessionChatStr
onFileDiffClick,
onFilePathClick,
onOpenHtmlFile,
canOpenHtmlFile,
messageFileDiffEntriesByTurn,
assistantActions,
assistantActionsMessageId,
Expand Down Expand Up @@ -279,6 +282,7 @@ const SessionChatStreamImpl = forwardRef<SessionChatStreamHandle, SessionChatStr
onFileDiffClick={hasFileDiffClick ? stableOnFileDiffClick : undefined}
onFilePathClick={hasFilePathClick ? stableOnFilePathClick : undefined}
onOpenHtmlFile={onOpenHtmlFile}
canOpenHtmlFile={canOpenHtmlFile}
lastAssistantMessageId={lastAssistantMessageId}
lastCompletedAssistantMessageId={lastCompletedAssistantMessageId}
messageFileDiffEntriesByTurn={messageFileDiffEntriesByTurn}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Check, Copy, Download, FileWarning, Loader2, X } from 'lucide-react';
import { Check, Copy, Download, ExternalLink, FileWarning, Loader2, X } from 'lucide-react';
import type { SessionFilePayload } from '@lody/shared';
import { Dialog, DialogClose, DialogContentWithoutClose, DialogTitle } from '@/ui/dialog';
import { Button } from '@/ui/button';
Expand All @@ -24,6 +24,7 @@ export type SessionFilePreviewPanelProps = {
status: SessionFilePreviewStatus;
onDownload: (file: SessionFilePayload) => void;
isDownloading?: boolean;
onOpenLivePreview?: (file: SessionFilePayload) => void;
};

/**
Expand All @@ -38,6 +39,7 @@ export function SessionFilePreviewPanel({
status,
onDownload,
isDownloading = false,
onOpenLivePreview,
}: SessionFilePreviewPanelProps) {
const { t } = useTranslation();
const markdown = isMarkdownFile(file.fileName);
Expand Down Expand Up @@ -123,7 +125,20 @@ export function SessionFilePreviewPanel({
<Download className="size-4" />
)}
</Button>
{/* Divider separates content actions (copy/download) from the window
{onOpenLivePreview ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={() => onOpenLivePreview(file)}
aria-label={t('sessions.openLivePreview', 'Open live preview')}
title={t('sessions.openLivePreview', 'Open live preview')}
>
<ExternalLink className="size-4" />
</Button>
) : null}
{/* Divider separates content actions (copy/download/live-preview) from the window
action (close), and the close sits inline in the same row instead
of floating in the corner over the buttons. */}
<span className="mx-0.5 h-4 w-px bg-border" aria-hidden="true" />
Expand Down Expand Up @@ -204,14 +219,27 @@ export type SessionFilePreviewDialogProps = SessionFilePreviewPanelProps & {
export function SessionFilePreviewDialog({
open,
onOpenChange,
onOpenLivePreview,
...panelProps
}: SessionFilePreviewDialogProps) {
// Re-key the panel per file so the rendered/raw toggle resets between files.
const panelKey = useMemo(() => panelProps.file.fileId, [panelProps.file.fileId]);
const handleOpenLivePreview = useMemo(() => {
if (!onOpenLivePreview) return undefined;
return (file: SessionFilePayload) => {
onOpenChange(false);
onOpenLivePreview(file);
};
}, [onOpenChange, onOpenLivePreview]);

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContentWithoutClose className="w-[calc(100vw-2rem)] max-w-3xl">
<SessionFilePreviewPanel key={panelKey} {...panelProps} />
<SessionFilePreviewPanel
key={panelKey}
{...panelProps}
onOpenLivePreview={handleOpenLivePreview}
/>
</DialogContentWithoutClose>
</Dialog>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ File attachments use `file` blocks; the product contract is in
Upload/download/preview helpers: `@/lib/session-file-{upload,download,presentation}.ts`.
- Agent-uploaded blocks may carry a workspace-relative `sourcePath` proven by the CLI's
containment check. It is provenance for reopening the live file, never a download path.
HTML clicks use it only for same-machine Sessions and enter the existing rendered file
preview. Remote HTML clicks reuse an active Browser connection; an available reported
candidate requires an explicit confirmation before creating its tunnel. Missing provenance
or preview state falls back to the ordinary attachment preview instead of guessing.
HTML clicks open the in-app preview dialog (retaining Download and Copy); when live provenance
is present, the dialog additionally offers an action to open the rendered file preview (local)
or Browser connection (remote). Missing provenance or preview state falls back to the ordinary
attachment preview without the live action.
- `@/lib/session-file-download.ts` `downloadSessionFile` branches by platform
(`isNativeAppShell()`): web/electron uses blob + `<a download>`; native mobile routes to
`@/lib/session-file-native-save.ts` (Capacitor Filesystem chunked base64 write to the
Expand Down
23 changes: 17 additions & 6 deletions packages/components/src/components/ai-gui/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ export interface SessionChatStreamViewProps {
onFilePathClick?: (filePath: string) => void;
/** Returns true when an HTML attachment click was routed to a richer surface. */
onOpenHtmlFile?: (file: SessionFilePayload) => boolean;
/** Returns true when an HTML attachment can be routed to a richer surface. */
canOpenHtmlFile?: (file: SessionFilePayload) => boolean;
lastAssistantMessageId?: string | null;
lastCompletedAssistantMessageId?: string | null;
messageFileDiffEntriesByTurn?: MessageFileDiffEntriesByTurn;
Expand All @@ -455,6 +457,7 @@ export interface SessionChatStreamViewProps {
const SessionChatActionContext = createContext<{
sendMessage?: (message: ClientToServer) => void;
openHtmlFile?: (file: SessionFilePayload) => boolean;
canOpenHtmlFile?: (file: SessionFilePayload) => boolean;
}>({});
const SessionImagePreviewContext = createContext<{
openImagePreview: (imageKey: string) => void;
Expand Down Expand Up @@ -1178,6 +1181,7 @@ export const SessionChatStreamView = forwardRef<
onFileDiffClick,
onFilePathClick,
onOpenHtmlFile,
canOpenHtmlFile,
lastAssistantMessageId = null,
lastCompletedAssistantMessageId = null,
messageFileDiffEntriesByTurn,
Expand Down Expand Up @@ -1626,8 +1630,9 @@ export const SessionChatStreamView = forwardRef<
() => ({
...(sendMessage ? { sendMessage } : {}),
...(onOpenHtmlFile ? { openHtmlFile: onOpenHtmlFile } : {}),
...(canOpenHtmlFile ? { canOpenHtmlFile } : {}),
}),
[onOpenHtmlFile, sendMessage]
[canOpenHtmlFile, onOpenHtmlFile, sendMessage]
);
const hasOnlyEmptyItem = items.length === 1 && items[0]?.type === 'empty';

Expand Down Expand Up @@ -4789,7 +4794,7 @@ export const SessionFileGroup = ({
const { t } = useTranslation();
const workspaceId = useAtomValue(currentWorkspaceIdAtom) as WorkspaceId | null;
const authToken = useAtomValue(authTokenAtom);
const { openHtmlFile } = useContext(SessionChatActionContext);
const { openHtmlFile, canOpenHtmlFile } = useContext(SessionChatActionContext);
const [previewFile, setPreviewFile] = useState<SessionFilePayload | null>(null);
const [previewStatus, setPreviewStatus] = useState<SessionFilePreviewStatus>({ kind: 'loading' });
const [downloadingId, setDownloadingId] = useState<string | null>(null);
Expand Down Expand Up @@ -4817,9 +4822,6 @@ export const SessionFileGroup = ({

const handlePreview = useCallback(
(file: SessionFilePayload) => {
if (isHtmlSessionFile(file) && openHtmlFile?.(file)) {
return;
}
setPreviewFile(file);
setPreviewStatus({ kind: 'loading' });
if (!workspaceId || !authToken) {
Expand Down Expand Up @@ -4856,7 +4858,7 @@ export const SessionFileGroup = ({
});
});
},
[authToken, openHtmlFile, sessionId, t, workspaceId]
[authToken, sessionId, t, workspaceId]
);

// The send path caps at 8 files/message, but a block list synced from another
Expand Down Expand Up @@ -4896,6 +4898,15 @@ export const SessionFileGroup = ({
status={previewStatus}
onDownload={handleDownload}
isDownloading={downloadingId === previewFile.fileId}
onOpenLivePreview={
isHtmlSessionFile(previewFile) &&
openHtmlFile &&
(canOpenHtmlFile ? canOpenHtmlFile(previewFile) : true)
? (file) => {
openHtmlFile(file);
}
: undefined
}
/>
) : null}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5337,6 +5337,25 @@ export const SessionChatInterface = memo(
}
return false;
});
const canOpenHtmlAttachment = useStableCallback((file: SessionFilePayload): boolean => {
const action = resolveSessionHtmlAttachmentAction({
isLocalSession,
sourcePath: file.sourcePath,
connectionStatus: session.previewConnection?.status,
candidateStatus: session.previewCandidate?.status,
});
switch (action.kind) {
case 'open-local-file':
return !!onOpenHtmlFile;
case 'open-existing-browser':
return !!onOpenExistingBrowser;
case 'confirm-reported-port':
return !!onOpenBrowser;
case 'fallback':
return false;
}
return false;
});
const openInIdeTarget = useMemo(
() =>
resolveSessionOpenInIdePathTarget({
Expand Down Expand Up @@ -5846,6 +5865,7 @@ export const SessionChatInterface = memo(
onFileDiffClick={onFileDiffClick}
onFilePathClick={onFilePathClick ? handleFilePathClick : undefined}
onOpenHtmlFile={handleOpenHtmlAttachment}
canOpenHtmlFile={canOpenHtmlAttachment}
messageFileDiffEntriesByTurn={messageFileDiffEntriesByTurn}
assistantActions={assistantQuickActions}
assistantActionsMessageId={latestCompletedProposedPlan?.entryId}
Expand Down
Loading