diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 7ea2a83..5f42d8a 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as ApiPathsRouteImport } from './routes/api/paths' import { Route as ApiMcpHealthRouteImport } from './routes/api/mcp-health' import { Route as ApiHistoryRouteImport } from './routes/api/history' import { Route as ApiGitReviewRouteImport } from './routes/api/git-review' +import { Route as ApiContextPreviewRouteImport } from './routes/api/context-preview' import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts' const NewRoute = NewRouteImport.update({ @@ -101,6 +102,11 @@ const ApiGitReviewRoute = ApiGitReviewRouteImport.update({ path: '/api/git-review', getParentRoute: () => rootRouteImport, } as any) +const ApiContextPreviewRoute = ApiContextPreviewRouteImport.update({ + id: '/api/context-preview', + path: '/api/context-preview', + getParentRoute: () => rootRouteImport, +} as any) const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ id: '/api/artifacts', path: '/api/artifacts', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/new': typeof NewRoute '/api/artifacts': typeof ApiArtifactsRoute + '/api/context-preview': typeof ApiContextPreviewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -130,6 +137,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/new': typeof NewRoute '/api/artifacts': typeof ApiArtifactsRoute + '/api/context-preview': typeof ApiContextPreviewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -149,6 +157,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/new': typeof NewRoute '/api/artifacts': typeof ApiArtifactsRoute + '/api/context-preview': typeof ApiContextPreviewRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -169,6 +178,7 @@ export interface FileRouteTypes { | '/connect' | '/new' | '/api/artifacts' + | '/api/context-preview' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -187,6 +197,7 @@ export interface FileRouteTypes { | '/connect' | '/new' | '/api/artifacts' + | '/api/context-preview' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -205,6 +216,7 @@ export interface FileRouteTypes { | '/connect' | '/new' | '/api/artifacts' + | '/api/context-preview' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -224,6 +236,7 @@ export interface RootRouteChildren { ConnectRoute: typeof ConnectRoute NewRoute: typeof NewRoute ApiArtifactsRoute: typeof ApiArtifactsRoute + ApiContextPreviewRoute: typeof ApiContextPreviewRoute ApiGitReviewRoute: typeof ApiGitReviewRoute ApiHistoryRoute: typeof ApiHistoryRoute ApiMcpHealthRoute: typeof ApiMcpHealthRoute @@ -345,6 +358,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGitReviewRouteImport parentRoute: typeof rootRouteImport } + '/api/context-preview': { + id: '/api/context-preview' + path: '/api/context-preview' + fullPath: '/api/context-preview' + preLoaderRoute: typeof ApiContextPreviewRouteImport + parentRoute: typeof rootRouteImport + } '/api/artifacts': { id: '/api/artifacts' path: '/api/artifacts' @@ -360,6 +380,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectRoute: ConnectRoute, NewRoute: NewRoute, ApiArtifactsRoute: ApiArtifactsRoute, + ApiContextPreviewRoute: ApiContextPreviewRoute, ApiGitReviewRoute: ApiGitReviewRoute, ApiHistoryRoute: ApiHistoryRoute, ApiMcpHealthRoute: ApiMcpHealthRoute, diff --git a/apps/codex-claw/src/routes/api/context-preview.ts b/apps/codex-claw/src/routes/api/context-preview.ts new file mode 100644 index 0000000..b3025c8 --- /dev/null +++ b/apps/codex-claw/src/routes/api/context-preview.ts @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { previewContextAttachment } from '../../server/context-attachments' + +export const Route = createFileRoute('/api/context-preview')({ + server: { + handlers: { + POST: async ({ request }) => { + try { + const body = await request.json().catch(() => ({})) + return json(await previewContextAttachment(body)) + } catch (err) { + return json( + { + error: err instanceof Error ? err.message : String(err), + }, + { status: 400 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/routes/api/send.ts b/apps/codex-claw/src/routes/api/send.ts index ab2c823..bb251df 100644 --- a/apps/codex-claw/src/routes/api/send.ts +++ b/apps/codex-claw/src/routes/api/send.ts @@ -7,6 +7,10 @@ import { resolveCodexSession, sendCodexPrompt, } from '../../server/codex-cli' +import { + buildContextAttachmentPrompt, + parseContextAttachments, +} from '../../server/context-attachments' import { buildRepositoryContextPrompt } from '../../server/repo-context' import type { RepoContextSelection } from '../../server/repo-context' @@ -141,10 +145,25 @@ export const Route = createFileRoute('/api/send')({ const contextSelections = parseContextSelections( body.contextSelections, ) - const contextBlock = + const parsedContextAttachments = parseContextAttachments( + body.contextAttachments, + ) + if (!parsedContextAttachments.ok) { + return json( + { ok: false, error: parsedContextAttachments.error }, + { status: 400 }, + ) + } + const repositoryContextBlock = contextSelections.length > 0 ? buildRepositoryContextPrompt(contextSelections) : undefined + const attachmentContextBlock = buildContextAttachmentPrompt( + parsedContextAttachments.attachments, + ) + const contextBlock = [repositoryContextBlock, attachmentContextBlock] + .filter(Boolean) + .join('\n\n') if ( !message.trim() && diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index fccdc87..5770558 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -2,6 +2,8 @@ import { getMessageTimestamp, normalizeSessions, readError } from './utils' import type { QueryClient } from '@tanstack/react-query' import type { ArtifactListResponse, + ContextAttachment, + ContextAttachmentPreviewInput, GatewayMessage, GitReviewPayload, HistoryResponse, @@ -210,6 +212,18 @@ export async function fetchRepoContext( return (await res.json()) as RepoContextPayload } +export async function previewContextAttachment( + input: ContextAttachmentPreviewInput, +): Promise { + const res = await fetch('/api/context-preview', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as ContextAttachment +} + export async function fetchGitReview(): Promise { const res = await fetch('/api/git-review') if (!res.ok) throw new Error(await readError(res)) diff --git a/apps/codex-claw/src/screens/chat/chat-screen-utils.ts b/apps/codex-claw/src/screens/chat/chat-screen-utils.ts index a81ae9d..bb8ec21 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen-utils.ts +++ b/apps/codex-claw/src/screens/chat/chat-screen-utils.ts @@ -1,4 +1,8 @@ -import type { GatewayMessage, RepoContextSelection } from './types' +import type { + ContextAttachment, + GatewayMessage, + RepoContextSelection, +} from './types' import type { AttachmentFile } from '@/components/attachment-button' import { randomUUID } from '@/lib/utils' @@ -12,6 +16,7 @@ export function createOptimisticMessage( body: string, attachments?: Array, contextSelections?: Array, + contextAttachments?: Array, ): OptimisticMessagePayload { const clientId = randomUUID() const optimisticId = `opt-${clientId}` @@ -40,13 +45,24 @@ export function createOptimisticMessage( if (body.trim()) { content.push({ type: 'text', text: body }) - } else if (contextSelections && contextSelections.length > 0) { - content.push({ - type: 'text', - text: + } else if ( + (contextSelections && contextSelections.length > 0) || + (contextAttachments && contextAttachments.length > 0) + ) { + const labels: Array = [] + if (contextSelections && contextSelections.length > 0) { + labels.push( 'Repository context: ' + - contextSelections.map((selection) => selection.path).join(', '), - }) + contextSelections.map((selection) => selection.path).join(', '), + ) + } + if (contextAttachments && contextAttachments.length > 0) { + labels.push( + 'Context attachments: ' + + contextAttachments.map((attachment) => attachment.title).join(', '), + ) + } + content.push({ type: 'text', text: labels.join('\n') }) } else if (attachments && attachments.length > 0) { content.push({ type: 'text', text: '' }) } diff --git a/apps/codex-claw/src/screens/chat/chat-screen.tsx b/apps/codex-claw/src/screens/chat/chat-screen.tsx index 5403294..fa93285 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen.tsx +++ b/apps/codex-claw/src/screens/chat/chat-screen.tsx @@ -47,7 +47,11 @@ import { shouldRedirectToConnect } from './hooks/use-chat-error-state' import { useChatRedirect } from './hooks/use-chat-redirect' import type { AttachmentFile } from '@/components/attachment-button' import type { ChatComposerHelpers } from './components/chat-composer' -import type { RepoContextSelection, RunProfileId } from './types' +import type { + ContextAttachment, + RepoContextSelection, + RunProfileId, +} from './types' import { useExport } from '@/hooks/use-export' import { useChatSettings } from '@/hooks/use-chat-settings' import { cn, randomUUID } from '@/lib/utils' @@ -240,6 +244,7 @@ export function ChatScreen({ skipOptimistic = false, attachments?: Array, contextSelections?: Array, + contextAttachments?: Array, runProfile?: RunProfileId, confirmedRisk?: boolean, ) { @@ -249,6 +254,7 @@ export function ChatScreen({ body, attachments, contextSelections, + contextAttachments, ) optimisticClientId = clientId appendHistoryMessage( @@ -275,6 +281,17 @@ export function ChatScreen({ content: a.base64, name: a.file.name, })) + const contextAttachmentsPayload = contextAttachments?.map((attachment) => ({ + id: attachment.id, + kind: attachment.kind, + title: attachment.title, + source: attachment.source, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + estimatedTokens: attachment.estimatedTokens, + text: attachment.text, + truncated: attachment.truncated, + })) fetch('/api/send', { method: 'POST', @@ -287,6 +304,7 @@ export function ChatScreen({ idempotencyKey: randomUUID(), attachments: attachmentsPayload, contextSelections, + contextAttachments: contextAttachmentsPayload, runProfile, confirmedRisk, }), @@ -367,19 +385,26 @@ export function ChatScreen({ (body: string, helpers: ChatComposerHelpers) => { const attachments = helpers.attachments const contextSelections = helpers.contextSelections + const contextAttachments = helpers.contextAttachments const runProfile = helpers.runProfile const confirmedRisk = helpers.confirmedRisk if ( body.length === 0 && (!attachments || attachments.length === 0) && - (!contextSelections || contextSelections.length === 0) + (!contextSelections || contextSelections.length === 0) && + (!contextAttachments || contextAttachments.length === 0) ) return helpers.reset() if (isNewChat) { const { clientId, optimisticId, optimisticMessage } = - createOptimisticMessage(body, attachments, contextSelections) + createOptimisticMessage( + body, + attachments, + contextSelections, + contextAttachments, + ) appendHistoryMessage(queryClient, 'new', 'new', optimisticMessage) setPendingGeneration(true) setSending(true) @@ -396,6 +421,7 @@ export function ChatScreen({ optimisticMessage, attachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, }) @@ -438,6 +464,7 @@ export function ChatScreen({ false, attachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, ) diff --git a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx index 1cb8873..7168f16 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx @@ -7,8 +7,17 @@ import { RepoContextPanel, RepoContextSummary, } from './repo-context-picker' +import { + ContextAttachmentButton, + ContextAttachmentPanel, + ContextAttachmentSummary, +} from './context-attachment-picker' import type { Ref } from 'react' -import type { RepoContextSelection, RunProfileId } from '../types' +import type { + ContextAttachment, + RepoContextSelection, + RunProfileId, +} from '../types' import type { AttachmentFile } from '@/components/attachment-button' import { @@ -35,6 +44,7 @@ type ChatComposerHelpers = { setValue: (value: string) => void attachments?: Array contextSelections?: Array + contextAttachments?: Array runProfile?: RunProfileId confirmedRisk?: boolean } @@ -74,6 +84,10 @@ function ChatComposerComponent({ const [contextSelections, setContextSelections] = useState< Array >([]) + const [contextAttachmentOpen, setContextAttachmentOpen] = useState(false) + const [contextAttachments, setContextAttachments] = useState< + Array + >([]) const [runProfile, setRunProfile] = useState('read-only-inspect') const [confirmedRisk, setConfirmedRisk] = useState(false) @@ -101,6 +115,8 @@ function ChatComposerComponent({ }) setContextSelections([]) setContextOpen(false) + setContextAttachments([]) + setContextAttachmentOpen(false) setConfirmedRisk(false) focusPrompt() }, [focusPrompt]) @@ -141,6 +157,11 @@ function ChatComposerComponent({ prev.filter((selection) => selection.path !== path), ) }, []) + const handleRemoveContextAttachment = useCallback((id: string) => { + setContextAttachments((prev) => + prev.filter((attachment) => attachment.id !== id), + ) + }, []) const activeRunProfile = useMemo(() => { return ( runProfiles.find((profile) => profile.id === runProfile) ?? runProfiles[0] @@ -175,12 +196,13 @@ function ChatComposerComponent({ const handleSubmit = useCallback(() => { if (disabled) return const body = valueRef.current.trim() - // Allow submit if there is text, an image, or selected repo context. + // Allow submit if there is text, an image, or selected context. const validAttachments = attachments.filter((a) => !a.error && a.base64) if ( body.length === 0 && validAttachments.length === 0 && - contextSelections.length === 0 + contextSelections.length === 0 && + contextAttachments.length === 0 ) return if (activeRunProfile.requiresConfirmation && !confirmedRisk) return @@ -189,6 +211,7 @@ function ChatComposerComponent({ setValue: setComposerValue, attachments: validAttachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, }) @@ -201,6 +224,7 @@ function ChatComposerComponent({ setComposerValue, attachments, contextSelections, + contextAttachments, runProfile, confirmedRisk, activeRunProfile.requiresConfirmation, @@ -229,11 +253,20 @@ function ChatComposerComponent({ selections={contextSelections} onRemove={handleRemoveContext} /> + + )} /> + ( + + setContextAttachmentOpen((current) => !current) + } + disabled={disabled} + buttonProps={{ + ...triggerProps, + className: cn('rounded-full', triggerProps.className), + }} + /> + )} + /> ( diff --git a/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx b/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx new file mode 100644 index 0000000..2d56d7b --- /dev/null +++ b/apps/codex-claw/src/screens/chat/components/context-attachment-picker.tsx @@ -0,0 +1,354 @@ +import { useRef, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Cancel01Icon, + FileAttachmentIcon, + FileUploadIcon, + LinkSquare01Icon, + PlusSignIcon, +} from '@hugeicons/core-free-icons' + +import { previewContextAttachment } from '../chat-queries' +import type { ContextAttachment } from '../types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +type ContextAttachmentSummaryProps = { + attachments: Array + onRemove: (id: string) => void +} + +type ContextAttachmentPanelProps = { + open: boolean + attachments: Array + onAttachmentsChange: (attachments: Array) => void +} + +const maxAttachmentCount = 6 +const acceptedDocumentTypes = + '.md,.markdown,.txt,.json,.pdf,text/markdown,text/plain,application/json,application/pdf' + +function formatBytes(bytes: number) { + if (bytes < 1024) return String(bytes) + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +} + +function attachmentIcon(attachment: ContextAttachment) { + return attachment.kind === 'url' ? LinkSquare01Icon : FileAttachmentIcon +} + +function attachmentKey(attachment: ContextAttachment) { + return attachment.id +} + +function fileToBase64(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(new Error('Document could not be read.')) + reader.onload = () => { + const value = typeof reader.result === 'string' ? reader.result : '' + const data = value.includes(',') + ? value.slice(value.indexOf(',') + 1) + : value + if (!data) { + reject(new Error('Document could not be read.')) + return + } + resolve(data) + } + reader.readAsDataURL(file) + }) +} + +function ContextAttachmentPreview({ + attachment, + action, +}: { + attachment: ContextAttachment + action?: React.ReactNode +}) { + return ( +
+
+ +
+
+ {attachment.title} +
+
+ {attachment.source} +
+
+ {formatBytes(attachment.sizeBytes)} + {attachment.estimatedTokens.toLocaleString()} tokens + {attachment.mimeType} + {attachment.truncated ? ( + truncated + ) : null} +
+
+ {action} +
+
+ {attachment.text} +
+
+ ) +} + +export function ContextAttachmentSummary({ + attachments, + onRemove, +}: ContextAttachmentSummaryProps) { + if (attachments.length === 0) return null + + return ( +
+ {attachments.map((attachment) => ( + + + {attachment.title} + + {attachment.estimatedTokens.toLocaleString()} + + + + ))} +
+ ) +} + +export function ContextAttachmentPanel({ + open, + attachments, + onAttachmentsChange, +}: ContextAttachmentPanelProps) { + const fileInputRef = useRef(null) + const [url, setUrl] = useState('') + const [draft, setDraft] = useState(null) + const [loading, setLoading] = useState<'url' | 'document' | null>(null) + const [error, setError] = useState(null) + const atLimit = attachments.length >= maxAttachmentCount + + async function handleUrlPreview() { + const nextUrl = url.trim() + if (!nextUrl || loading) return + setLoading('url') + setError(null) + setDraft(null) + try { + setDraft(await previewContextAttachment({ kind: 'url', url: nextUrl })) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(null) + } + } + + async function handleDocumentChange( + event: React.ChangeEvent, + ) { + const file = event.target.files?.[0] + event.target.value = '' + if (!file || loading) return + setLoading('document') + setError(null) + setDraft(null) + try { + const content = await fileToBase64(file) + setDraft( + await previewContextAttachment({ + kind: 'document', + name: file.name, + mimeType: file.type, + content, + }), + ) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(null) + } + } + + function addDraft() { + if (!draft || atLimit) return + onAttachmentsChange([...attachments, draft]) + setDraft(null) + if (draft.kind === 'url') setUrl('') + } + + if (!open) return null + + return ( +
+
+
+ + setUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + void handleUrlPreview() + } + }} + placeholder="https://example.com/reference" + size="sm" + className="min-w-[220px] flex-1" + /> + + void handleDocumentChange(event)} + className="hidden" + aria-hidden="true" + /> + +
+
+ + {attachments.length} / {maxAttachmentCount} attached + + markdown, text, JSON, PDF +
+
+ + {error ?
{error}
: null} + {atLimit ? ( +
+ Remove a context attachment before adding another. +
+ ) : null} + +
+ {draft ? ( + + + Add context + + } + /> + ) : null} + {attachments.map((attachment) => ( + + onAttachmentsChange( + attachments.filter((item) => item.id !== attachment.id), + ) + } + aria-label={'Remove context ' + attachment.title} + > + + + } + /> + ))} +
+
+ ) +} + +export function ContextAttachmentButton({ + open, + onToggle, + disabled, + buttonProps, +}: { + open: boolean + onToggle: () => void + disabled?: boolean + buttonProps?: React.ComponentProps +}) { + return ( + + ) +} diff --git a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts index edda96e..d80871c 100644 --- a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts +++ b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts @@ -9,6 +9,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { AttachmentFile } from '@/components/attachment-button' import type { + ContextAttachment, HistoryResponse, RepoContextSelection, RunProfileId, @@ -30,6 +31,7 @@ type UseChatPendingSendInput = { skipOptimistic: boolean, attachments?: Array, contextSelections?: Array, + contextAttachments?: Array, runProfile?: RunProfileId, confirmedRisk?: boolean, ) => void @@ -106,6 +108,7 @@ export function useChatPendingSend({ true, pending.attachments, pending.contextSelections, + pending.contextAttachments, pending.runProfile, pending.confirmedRisk, ) diff --git a/apps/codex-claw/src/screens/chat/pending-send.ts b/apps/codex-claw/src/screens/chat/pending-send.ts index a67eb1a..64ca3e6 100644 --- a/apps/codex-claw/src/screens/chat/pending-send.ts +++ b/apps/codex-claw/src/screens/chat/pending-send.ts @@ -1,4 +1,5 @@ import type { + ContextAttachment, GatewayMessage, RepoContextSelection, RunProfileId, @@ -12,6 +13,7 @@ export type PendingSendPayload = { optimisticMessage: GatewayMessage attachments?: Array contextSelections?: Array + contextAttachments?: Array runProfile?: RunProfileId confirmedRisk?: boolean } diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index 1bc58db..bf30fe6 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -189,6 +189,32 @@ export type RepoContextPayload = { estimate: RepoContextEstimate } +export type ContextAttachmentKind = 'url' | 'document' + +export type ContextAttachment = { + id: string + kind: ContextAttachmentKind + title: string + source: string + mimeType: string + sizeBytes: number + estimatedTokens: number + text: string + truncated: boolean +} + +export type ContextAttachmentPreviewInput = + | { + kind: 'url' + url: string + } + | { + kind: 'document' + name: string + mimeType: string + content: string + } + export type GitFileState = 'staged' | 'unstaged' | 'untracked' | 'deleted' export type GitReviewFile = { diff --git a/apps/codex-claw/src/server/context-attachments.test.ts b/apps/codex-claw/src/server/context-attachments.test.ts new file mode 100644 index 0000000..ac5ce28 --- /dev/null +++ b/apps/codex-claw/src/server/context-attachments.test.ts @@ -0,0 +1,110 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' + +import { + buildContextAttachmentPrompt, + parseContextAttachments, + previewContextAttachment, + previewDocumentContextAttachment, +} from './context-attachments' + +function base64(value: string) { + return Buffer.from(value, 'utf8').toString('base64') +} + +describe('context attachments', function () { + it('previews an explicit URL fetch without sending it directly', async function () { + const fetcher = function fetchPreview() { + return Promise.resolve( + new Response( + 'Bug report

Issue

Expected behavior