From 3b3465f2a9dbc541a8806e39d2889abb77b93f5d Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:00:51 +0300 Subject: [PATCH 01/10] fix(web): changing projects no longer creates a draft (#9097) --- apps/web/src/components/ChatView.tsx | 1 + .../src/components/chat/DraftHeroHeadline.tsx | 39 +++- apps/web/src/composerDraftStore.test.ts | 197 ++++-------------- apps/web/src/composerDraftStore.ts | 195 ++++++----------- apps/web/src/hooks/useHandleNewThread.ts | 54 +---- .../web/src/lib/attachmentUploadQueue.test.ts | 164 +-------------- apps/web/src/lib/chatThreadActions.test.ts | 17 ++ apps/web/src/lib/chatThreadActions.ts | 19 +- 8 files changed, 175 insertions(+), 511 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2783f3266d6c..a475230fe829 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7367,6 +7367,7 @@ function ChatViewContent(props: ChatViewProps) { } > diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 57f9d7792251..04bbeb6ce49b 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,11 +1,13 @@ +import type { DraftId } from "~/composerDraftStore"; +import { useComposerDraftStore } from "~/composerDraftStore"; import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { FolderPlusIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; -import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useClientSettings } from "~/hooks/useSettings"; +import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { selectProjectGroupingSettings } from "~/logicalProject"; import { buildSidebarProjectPickerEntries, @@ -26,11 +28,13 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; interface DraftHeroHeadlineProps { + readonly draftId: DraftId | null; readonly activeProjectRef: ScopedProjectRef | null; readonly activeProjectTitle: string | null; } export function DraftHeroHeadline({ + draftId, activeProjectRef, activeProjectTitle, }: DraftHeroHeadlineProps) { @@ -40,7 +44,12 @@ export function DraftHeroHeadline({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); - const handleNewThread = useNewThreadHandler(); + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, + ); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); + const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const environmentLabelById = useMemo( @@ -126,12 +135,26 @@ export function DraftHeroHeadline({ return; } const project = entry.targetProject; - // Changing the repo of a draft moves the typed content along: - // the user started writing in the wrong project, not a new task. - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { - replace: true, - carryComposerContent: true, - }); + if (!draftId) { + return; + } + // Project selection changes the target of the open draft in + // place. The prompt stays in the same composer session, so the + // sidebar only gets a draft row if the user later navigates away. + const currentDraft = getComposerDraft(draftId); + setLogicalProjectDraftThreadId( + entry.group.projectKey, + scopeProjectRef(project.environmentId, project.id), + draftId, + ); + if (!hasExplicitComposerModelSelection(currentDraft)) { + applyStickyState(draftId); + if (project.defaultModelSelection) { + setModelSelection(draftId, project.defaultModelSelection, { + replaceOptions: true, + }); + } + } }} > {projectPickerEntries.map(({ group }) => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index ac797529308d..4e8e9d200bf6 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -636,167 +636,6 @@ describe("composerDraftStore file attachments", () => { }); }); -describe("composerDraftStore moveComposerPromptAndImages", () => { - const sourceDraftId = DraftId.make("draft-move-source"); - const destinationDraftId = DraftId.make("draft-move-destination"); - let originalRevokeObjectUrl: typeof URL.revokeObjectURL; - let revokeSpy: ReturnType void>>; - - beforeEach(() => { - resetComposerDraftStore(); - originalRevokeObjectUrl = URL.revokeObjectURL; - revokeSpy = vi.fn(); - URL.revokeObjectURL = revokeSpy; - }); - - afterEach(() => { - URL.revokeObjectURL = originalRevokeObjectUrl; - }); - - it("moves prompt and images to the destination without revoking preview URLs", () => { - const store = useComposerDraftStore.getState(); - store.setPrompt(sourceDraftId, "fix the login redirect"); - store.addImages(sourceDraftId, [makeImage({ id: "img-move", previewUrl: "blob:move" })]); - - store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId); - - expect(draftByKey(sourceDraftId)).toBeUndefined(); - const destination = draftByKey(destinationDraftId); - expect(destination?.prompt).toBe("fix the login redirect"); - expect(destination?.images.map((image) => image.id)).toEqual(["img-move"]); - expect(revokeSpy).not.toHaveBeenCalled(); - }); - - it("keeps session-bound contexts on the source and strips their placeholders from the moved prompt", () => { - const sourceThreadId = ThreadId.make("thread-move-source"); - const sourceThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, sourceThreadId); - const store = useComposerDraftStore.getState(); - store.addTerminalContext(sourceThreadRef, makeTerminalContext({ id: "ctx-stay" })); - store.setPrompt(sourceThreadRef, `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} explain this error`); - - store.moveComposerPromptAndImages(sourceThreadRef, destinationDraftId); - - const source = draftFor(sourceThreadId, TEST_ENVIRONMENT_ID); - expect(source?.terminalContexts.map((context) => context.id)).toEqual(["ctx-stay"]); - expect(source?.prompt).toBe(INLINE_TERMINAL_CONTEXT_PLACEHOLDER); - expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error"); - }); - - it("keeps hydrated file references on their original environment", () => { - const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source")); - const destinationRef = scopeThreadRef( - OTHER_TEST_ENVIRONMENT_ID, - ThreadId.make("thread-file-destination"), - ); - const store = useComposerDraftStore.getState(); - store.setPrompt(sourceRef, "review the report"); - store.addFiles(sourceRef, [ - { - ...makeFile("file-hydrated"), - file: null, - uploadedAttachmentId: "pending-report-pdf", - uploadEnvironmentId: TEST_ENVIRONMENT_ID, - }, - ]); - - store.moveComposerPromptAndImages(sourceRef, destinationRef); - - expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([ - "file-hydrated", - ]); - expect(store.getComposerDraft(destinationRef)?.files).toEqual([]); - expect(store.getComposerDraft(destinationRef)?.prompt).toBe("review the report"); - }); - - it("moves files across environments when the original browser file remains available", () => { - const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-file-source")); - const destinationRef = scopeThreadRef( - OTHER_TEST_ENVIRONMENT_ID, - ThreadId.make("thread-file-destination"), - ); - const store = useComposerDraftStore.getState(); - store.addFiles(sourceRef, [ - { - ...makeFile("file-local"), - uploadedAttachmentId: "pending-source-env", - uploadEnvironmentId: TEST_ENVIRONMENT_ID, - }, - ]); - - store.moveComposerPromptAndImages(sourceRef, destinationRef); - - expect(store.getComposerDraft(sourceRef)).toBeNull(); - const moved = store.getComposerDraft(destinationRef)?.files; - expect(moved?.map((file) => file.id)).toEqual(["file-local"]); - // The source-environment upload is unreachable from the destination; the - // move drops it so the destination upload can mint its own. - expect(moved?.[0]?.uploadedAttachmentId).toBeUndefined(); - expect(moved?.[0]?.uploadEnvironmentId).toBeUndefined(); - }); - - it("does not duplicate a file the destination already holds", () => { - const sourceRef = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("thread-dup-source")); - const destinationRef = scopeThreadRef( - TEST_ENVIRONMENT_ID, - ThreadId.make("thread-dup-destination"), - ); - const store = useComposerDraftStore.getState(); - // Same metadata key on both sides; the ids differ. - store.addFiles(sourceRef, [makeFile("file-copy-a")]); - store.addFiles(destinationRef, [makeFile("file-copy-b")]); - - store.moveComposerPromptAndImages(sourceRef, destinationRef); - - expect(store.getComposerDraft(destinationRef)?.files.map((file) => file.id)).toEqual([ - "file-copy-b", - ]); - expect(store.getComposerDraft(sourceRef)?.files.map((file) => file.id)).toEqual([ - "file-copy-a", - ]); - }); - - it("keeps overflow attachments on the source when the destination is nearly full", () => { - const store = useComposerDraftStore.getState(); - store.addImages( - destinationDraftId, - Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) => - makeImage({ - id: `destination-${index}`, - name: `destination-${index}.png`, - previewUrl: `blob:destination-${index}`, - }), - ), - ); - store.addImages(sourceDraftId, [ - makeImage({ id: "source-first", name: "first.png", previewUrl: "blob:first" }), - makeImage({ id: "source-second", name: "second.png", previewUrl: "blob:second" }), - ]); - store.addFiles(sourceDraftId, [makeFile("source-file")]); - - store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId); - - expect(store.getComposerDraft(destinationDraftId)?.images).toHaveLength( - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - ); - expect(store.getComposerDraft(destinationDraftId)?.files).toEqual([]); - expect(store.getComposerDraft(sourceDraftId)?.images.map((image) => image.id)).toEqual([ - "source-second", - ]); - expect(store.getComposerDraft(sourceDraftId)?.files.map((file) => file.id)).toEqual([ - "source-file", - ]); - }); - - it("is a no-op when source and destination are the same target", () => { - const store = useComposerDraftStore.getState(); - store.setPrompt(sourceDraftId, "keep me"); - - store.moveComposerPromptAndImages(sourceDraftId, sourceDraftId); - - expect(draftByKey(sourceDraftId)?.prompt).toBe("keep me"); - }); -}); - describe("composerDraftStore syncPersistedAttachments", () => { const threadId = ThreadId.make("thread-sync-persisted"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); @@ -1275,6 +1114,18 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("removes a draft's previous project mapping when retargeted in place", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep this prompt"); + + store.setProjectDraftThreadId(otherProjectRef, draftId, { threadId }); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(otherProjectRef)?.draftId).toBe(draftId); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }); + it("rotates a failed bootstrap thread id without losing its draft", () => { const store = useComposerDraftStore.getState(); const retryThreadId = ThreadId.make("thread-retry"); @@ -1704,6 +1555,30 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("clears stale upload metadata when retargeting a draft to another environment", () => { + const store = useComposerDraftStore.getState(); + const hydratedFile: ComposerFileAttachment = { + ...makeFile("file-cross-environment"), + file: null, + uploadedAttachmentId: "local-environment-upload", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }; + + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.addFiles(draftId, [hydratedFile]); + + store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); + + const file = store.getComposerDraft(draftId)?.files[0]; + expect(file).toMatchObject({ + id: hydratedFile.id, + file: null, + }); + expect(file?.uploadedAttachmentId).toBeUndefined(); + expect(file?.uploadEnvironmentId).toBeUndefined(); + expect(file && composerFileNeedsReattach(file)).toBe(true); + }); + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 886c89071d10..fe1dab199763 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -45,7 +45,6 @@ import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, - stripInlineTerminalContextPlaceholders, } from "./lib/terminalContext"; import { type ElementContextDraft, @@ -116,6 +115,29 @@ export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean return file.file === null && file.uploadedAttachmentId === undefined; } +function clearStaleFileUploadMetadata( + draft: ComposerThreadDraftState, + environmentId: EnvironmentId, +): ComposerThreadDraftState { + let changed = false; + const files = draft.files.map((file) => { + if ( + (file.uploadedAttachmentId === undefined && file.uploadEnvironmentId === undefined) || + file.uploadEnvironmentId === environmentId + ) { + return file; + } + + changed = true; + const nextFile = { ...file }; + delete nextFile.uploadedAttachmentId; + delete nextFile.uploadEnvironmentId; + return nextFile; + }); + + return changed ? { ...draft, files } : draft; +} + export const PersistedComposerFileAttachment = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -435,7 +457,11 @@ interface ComposerDraftStoreState { getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; - /** Creates or updates the draft session tracked for a logical project. */ + /** + * Creates or updates the draft session tracked for a logical project. + * Reassigning an existing draft removes its previous logical-project + * mapping so one session cannot resolve from two projects. + */ setLogicalProjectDraftThreadId: ( logicalProjectKey: string, projectRef: ScopedProjectRef, @@ -606,13 +632,6 @@ interface ComposerDraftStoreState { * prompt stash. Session-bound context stays in the source draft. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; - /** - * Moves prompt text and transferable attachments into another composer. - * Attachments over the destination limit and uploaded files that belong to - * another environment stay in the source draft. Terminal and element - * context, preview annotations, and review comments also stay in the source. - */ - moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -2515,18 +2534,53 @@ const composerDraftStore = create()( options, ); const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; - if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) { + const hasNoStaleMappingsForDraft = Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + ).every( + ([logicalKey, mappedDraftId]) => + mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey, + ); + if ( + hasSameLogicalMapping && + hasNoStaleMappingsForDraft && + draftThreadsEqual(existingThread, nextDraftThread) + ) { return state; } - const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = { - ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, - [normalizedLogicalProjectKey]: draftId, - }; + // A draft session belongs to one logical project at a time. When + // an open draft is retargeted in place, remove any old mapping + // for that same draft so the previous project cannot resolve it. + const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = + Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([logicalKey, mappedDraftId]) => + mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey, + ), + ); + nextLogicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey] = + draftId; const nextDraftThreadsByThreadKey: Record = { ...state.draftThreadsByThreadKey, [draftId]: nextDraftThread, }; + const existingDraft = state.draftsByThreadKey[draftId]; let nextDraftsByThreadKey = state.draftsByThreadKey; + if ( + existingThread && + existingThread.environmentId !== projectRef.environmentId && + existingDraft !== undefined + ) { + const nextDraft = clearStaleFileUploadMetadata( + existingDraft, + projectRef.environmentId, + ); + if (nextDraft !== existingDraft) { + nextDraftsByThreadKey = { + ...state.draftsByThreadKey, + [draftId]: nextDraft, + }; + } + } const previousDraftThread = previousThreadKeyForLogicalProject === undefined ? undefined @@ -2550,7 +2604,7 @@ const composerDraftStore = create()( ) { delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { - nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + nextDraftsByThreadKey = { ...nextDraftsByThreadKey }; delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; } } @@ -3805,117 +3859,6 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - moveComposerPromptAndImages: (from, to) => { - const fromKey = resolveComposerDraftKey(get(), from) ?? ""; - const toKey = resolveComposerDraftKey(get(), to) ?? ""; - if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) { - return; - } - set((state) => { - const source = state.draftsByThreadKey[fromKey]; - if (!source) { - return state; - } - const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft(); - const destinationEnvironmentId = - typeof to === "string" - ? (state.draftThreadsByThreadKey[toKey]?.environmentId ?? - parseScopedThreadKey(toKey)?.environmentId ?? - null) - : to.environmentId; - // A file the destination already holds (same id, or same - // metadata key) stays behind instead of duplicating there. - const destinationFileIds = new Set(destination.files.map((file) => file.id)); - const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey)); - const transferableFiles = source.files.filter( - (file) => - (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) && - !destinationFileIds.has(file.id) && - !destinationFileKeys.has(composerFileDedupKey(file)), - ); - const remainingAttachmentSlots = Math.max( - 0, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - - destination.images.length - - destination.files.length, - ); - const movedImages = source.images.slice(0, remainingAttachmentSlots); - const movedImageIds = new Set(movedImages.map((image) => image.id)); - const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id)); - const movedFiles = transferableFiles - .slice(0, remainingAttachmentSlots - movedImages.length) - .map((file) => { - // A byte-backed file moving across environments re-uploads at - // the destination. Keeping the source-environment upload id - // would mark it uploaded after a reload with no local bytes - // and no valid upload anywhere the destination can reach. The - // upload queue keeps the source upload as fallback, then - // deletes it after the destination upload succeeds. Abandoned - // uploads still expire through the server sweep. - if ( - file.file === null || - file.uploadEnvironmentId === undefined || - file.uploadEnvironmentId === destinationEnvironmentId - ) { - return file; - } - const { uploadedAttachmentId: _a, uploadEnvironmentId: _e, ...rest } = file; - return rest; - }); - const movedFileIds = new Set(movedFiles.map((file) => file.id)); - const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id)); - // Inline placeholders reference the source's terminal contexts, - // which stay behind; re-anchor the moved prompt to whatever - // contexts the destination already holds. - const movedPrompt = ensureInlineTerminalContextPlaceholders( - stripInlineTerminalContextPlaceholders(source.prompt), - destination.terminalContexts.length, - ); - const nextDestination: ComposerThreadDraftState = { - ...destination, - prompt: movedPrompt, - images: [...destination.images, ...movedImages], - files: [...destination.files, ...movedFiles], - nonPersistedImageIds: [ - ...destination.nonPersistedImageIds, - ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)), - ], - persistedAttachments: [ - ...destination.persistedAttachments, - ...source.persistedAttachments.filter((attachment) => - movedImageIds.has(attachment.id), - ), - ], - }; - // Same clearing shape as clearComposerPromptAndImages, but the - // preview URLs are NOT revoked: the images moved and their blobs - // are still referenced from the destination. - const nextSource: ComposerThreadDraftState = { - ...source, - prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), - images: retainedImages, - files: retainedFiles, - nonPersistedImageIds: source.nonPersistedImageIds.filter( - (imageId) => !movedImageIds.has(imageId), - ), - persistedAttachments: source.persistedAttachments.filter( - (attachment) => !movedImageIds.has(attachment.id), - ), - }; - const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; - if (shouldRemoveDraft(nextSource)) { - delete nextDraftsByThreadKey[fromKey]; - } else { - nextDraftsByThreadKey[fromKey] = nextSource; - } - if (shouldRemoveDraft(nextDestination)) { - delete nextDraftsByThreadKey[toKey]; - } else { - nextDraftsByThreadKey[toKey] = nextDestination; - } - return { draftsByThreadKey: nextDraftsByThreadKey }; - }); - }, }; }, { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 78e17a76b374..438cda84c622 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -25,6 +25,7 @@ import { import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import { readThreadShell, useProjects, useThread } from "../state/entities"; import { + hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; @@ -33,7 +34,6 @@ import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; -import { toastManager } from "../components/ui/toast"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -78,14 +78,6 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; - /** - * Move the viewed draft's typed content and transferable attachments into the - * draft this request lands on. Set by the draft repo picker: the - * user started writing in the wrong project and the text should - * follow them. Explicit new-thread surfaces leave this unset and - * keep mint-fresh semantics. - */ - carryComposerContent?: boolean; }, // Which draft the thread ended up in, so a caller that has something to put in it — a // prepared checkout, a task to write — addresses that one rather than looking the project @@ -97,7 +89,6 @@ export function useNewThreadHandler() { getDraftSession, getDraftThread, applyStickyState, - moveComposerPromptAndImages, setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, @@ -138,39 +129,6 @@ export function useNewThreadHandler() { carrySourceShell?.interactionMode ?? carrySourceDraft?.interactionMode ?? null; - // Content only moves when the caller opted in and the user is looking - // at a draft. The content check happens at move time, not here: the - // paths below await, and text typed during those awaits must still - // come along. - const carryContentSourceDraftId = - options?.carryComposerContent === true && currentRouteTarget?.kind === "draft" - ? currentRouteTarget.draftId - : null; - const carryComposerContentTo = (destinationDraftId: DraftId) => { - if ( - carryContentSourceDraftId && - carryContentSourceDraftId !== destinationDraftId && - // Never clobber a destination the user already invested in — the - // move overwrites the destination prompt, so a concurrent repo - // change that carried content first must win. - !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) && - composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId)) - ) { - moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId); - // The move caps at the destination's free slots and skips - // duplicates, so images and files can both stay behind. - const remainingDraft = getComposerDraft(carryContentSourceDraftId); - const remainingCount = - (remainingDraft?.files.length ?? 0) + (remainingDraft?.images.length ?? 0); - if (remainingCount > 0) { - toastManager.add({ - type: "warning", - title: `${remainingCount} attachment${remainingCount === 1 ? " stayed" : "s stayed"} in the original draft`, - description: "Return to the original draft or attach the files again.", - }); - } - } - }; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && @@ -310,11 +268,7 @@ export function useNewThreadHandler() { // is looking at, because explicit picks are the only thing the // flag protects. const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); - const storedActiveSelection = storedDraft?.activeProvider - ? storedDraft.modelSelectionByProvider[storedDraft.activeProvider] - : undefined; - const storedDraftHasExplicitModelPick = - Boolean(storedActiveSelection) && storedDraft?.modelSelectionExplicit === true; + const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); if (!storedDraftHasExplicitModelPick) { applyStickyState(emptyStoredDraftThread.draftId); const modelSelectionOverride = resolveModelSelectionOverride( @@ -343,7 +297,6 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); - carryComposerContentTo(emptyStoredDraftThread.draftId); const opened = { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, @@ -431,7 +384,6 @@ export function useNewThreadHandler() { interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), }); - carryComposerContentTo(racedDraft.draftId); await router.navigate({ to: "/draft/$draftId", params: { draftId: racedDraft.draftId }, @@ -461,8 +413,6 @@ export function useNewThreadHandler() { // state. The project default wins when both are present. setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } - carryComposerContentTo(draftId); - await router.navigate({ to: "/draft/$draftId", params: { draftId }, diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index 9f754baf500d..d7b30e452aab 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -1,5 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -326,83 +325,6 @@ describe("attachmentUploadQueue", () => { } }); - it("persists a pending upload on the same-environment draft that receives its file", async () => { - const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-background-move-source")); - const destination = scopeThreadRef( - firstEnvironment, - ThreadId.make("thread-background-move-destination"), - ); - const file = makeFile("background-move"); - const store = useComposerDraftStore.getState(); - store.addFiles(source, [file]); - - try { - startAttachmentUpload({ - environmentId: firstEnvironment, - image: file, - draftTarget: source, - }); - await Promise.resolve(); - store.moveComposerPromptAndImages(source, destination); - const sourceAfterMove = store.getComposerDraft(source); - - // The destination never starts the existing job again. Its completion - // must find the file in current store state instead of the captured row. - const settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[0]!.complete(); - await settled; - - expect(store.getComposerDraft(source)).toEqual(sourceAfterMove); - expect(store.getComposerDraft(destination)?.files).toMatchObject([ - { - id: file.id, - uploadedAttachmentId: "pending-environment-1-background-move.pdf", - uploadEnvironmentId: firstEnvironment, - }, - ]); - } finally { - store.clearComposerContent(source); - store.clearComposerContent(destination); - } - }); - - it("does not stamp an old-environment upload after its file moves environments", async () => { - const source = scopeThreadRef( - firstEnvironment, - ThreadId.make("thread-cross-environment-source"), - ); - const destination = scopeThreadRef( - secondEnvironment, - ThreadId.make("thread-cross-environment-destination"), - ); - const file = makeFile("cross-environment-move"); - const store = useComposerDraftStore.getState(); - store.addFiles(source, [file]); - - try { - startAttachmentUpload({ - environmentId: firstEnvironment, - image: file, - draftTarget: source, - }); - await Promise.resolve(); - store.moveComposerPromptAndImages(source, destination); - - const settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[0]!.complete(); - await settled; - - expect(store.getComposerDraft(source)).toBeNull(); - const movedFile = store.getComposerDraft(destination)?.files[0]; - expect(movedFile?.id).toBe(file.id); - expect(movedFile?.uploadedAttachmentId).toBeUndefined(); - expect(movedFile?.uploadEnvironmentId).toBeUndefined(); - } finally { - store.clearComposerContent(source); - store.clearComposerContent(destination); - } - }); - it("verifies an uploaded file reference before restoring it", async () => { const file: ComposerFileAttachment = { ...makeFile("restored"), @@ -849,90 +771,6 @@ describe("attachmentUploadQueue", () => { }); }); - it("releases a moved file's source upload only after its destination upload succeeds", async () => { - const source = scopeThreadRef(firstEnvironment, ThreadId.make("thread-file-move-source")); - const destination = scopeThreadRef( - secondEnvironment, - ThreadId.make("thread-file-move-destination"), - ); - const file = makeFile("moved-report"); - const store = useComposerDraftStore.getState(); - store.addFiles(source, [file]); - - try { - startAttachmentUpload({ - environmentId: firstEnvironment, - image: file, - draftTarget: source, - }); - await Promise.resolve(); - let settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[0]!.complete(); - await settled; - - const sourceAttachmentId = store.getComposerDraft(source)?.files[0]?.uploadedAttachmentId; - expect(sourceAttachmentId).toBe("pending-environment-1-moved-report.pdf"); - - store.moveComposerPromptAndImages(source, destination); - const movedFile = store.getComposerDraft(destination)?.files[0]; - expect(movedFile).toMatchObject({ - id: file.id, - file: file.file, - }); - expect(movedFile?.uploadedAttachmentId).toBeUndefined(); - expect(movedFile?.uploadEnvironmentId).toBeUndefined(); - - startAttachmentUpload({ - environmentId: secondEnvironment, - image: movedFile!, - draftTarget: destination, - }); - await Promise.resolve(); - - const sourceDeletesBeforeDestinationUpload = mocks.runAtomCommand.mock.calls.filter( - ([, command, target]) => - command === mocks.removeUpload && - ( - target as { - readonly environmentId: EnvironmentId; - readonly input: { readonly attachmentId: string }; - } - ).environmentId === firstEnvironment && - (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId === - sourceAttachmentId, - ); - expect(sourceDeletesBeforeDestinationUpload).toEqual([]); - - settled = awaitAttachmentUploads([file.id]); - TestXmlHttpRequest.requests[1]!.complete(); - await settled; - - const sourceDeletesAfterDestinationUpload = mocks.runAtomCommand.mock.calls.filter( - ([, command, target]) => - command === mocks.removeUpload && - ( - target as { - readonly environmentId: EnvironmentId; - readonly input: { readonly attachmentId: string }; - } - ).environmentId === firstEnvironment && - (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId === - sourceAttachmentId, - ); - expect(sourceDeletesAfterDestinationUpload).toHaveLength(1); - expect(store.getComposerDraft(destination)?.files).toMatchObject([ - { - id: file.id, - uploadedAttachmentId: "pending-environment-2-moved-report.pdf", - uploadEnvironmentId: secondEnvironment, - }, - ]); - } finally { - store.clearComposerContent(source); - store.clearComposerContent(destination); - } - }); - it("does not let stalled uploads block another environment", async () => { const images = ["image-a", "image-b", "image-c", "image-d"].map(makeImage); for (const image of images) { diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index ee555231e43c..c145404a74e2 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, + hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, startNewThreadFromContext, @@ -37,6 +38,22 @@ function createContext(overrides: Partial = {}): ChatTh } describe("chatThreadActions", () => { + it("only treats an active stored selection marked explicit as an explicit pick", () => { + const draft = { + activeProvider: PROJECT_DEFAULT_SELECTION.instanceId, + modelSelectionByProvider: { + [PROJECT_DEFAULT_SELECTION.instanceId]: PROJECT_DEFAULT_SELECTION, + }, + modelSelectionExplicit: true, + }; + + expect(hasExplicitComposerModelSelection(draft)).toBe(true); + expect(hasExplicitComposerModelSelection({ ...draft, modelSelectionExplicit: false })).toBe( + false, + ); + expect(hasExplicitComposerModelSelection({ ...draft, activeProvider: null })).toBe(false); + }); + it("does not carry a non-explicit model from the destination draft back into itself", () => { expect( resolveNewThreadModelSelectionOverride({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4336fa5a825c..c14a26d03d1c 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -5,7 +5,12 @@ import type { ProjectId, ScopedProjectRef, } from "@t3tools/contracts"; -import type { DraftThreadEnvMode } from "../composerDraftStore"; +import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; + +type ComposerModelSelectionState = Pick< + ComposerThreadDraftState, + "activeProvider" | "modelSelectionByProvider" | "modelSelectionExplicit" +>; interface ThreadContextLike { environmentId: EnvironmentId; @@ -51,6 +56,18 @@ export function resolveNewThreadModelSelectionOverride(input: { ); } +export function hasExplicitComposerModelSelection( + draft: ComposerModelSelectionState | null | undefined, +): boolean { + const activeProvider = draft?.activeProvider; + return ( + draft?.modelSelectionExplicit === true && + activeProvider !== null && + activeProvider !== undefined && + draft.modelSelectionByProvider[activeProvider] !== undefined + ); +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { From d0b4acbd13b2b602710e4a7d60c42f4799a409be Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:03:15 -0700 Subject: [PATCH 02/10] fix(web): keep theme placeholder text dimmer than entered text (#9104) --- apps/web/src/themePalette.test.ts | 3 +++ apps/web/src/themePalette.ts | 2 +- apps/web/src/vscodeThemeImport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 6c0718e2f1aa..562ebc263764 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -157,6 +157,9 @@ describe("theme files", () => { expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( + contrastRatio(colors.text, colors.surfaceRaised), + ); expect(colors.secondaryLabel).toBe(colors.textMuted); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); expect( diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 289082efef0f..458d4539e270 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -917,7 +917,7 @@ export function createVividThemeColors( solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), ); const mutedForeground = foregroundOn(mutedRgb); - const placeholder = foregroundOn(surfaceRaisedRgb); + const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6)); const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index e4fcdb907abb..59f732bf4a12 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -90,6 +90,28 @@ describe("VS Code theme import", () => { expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar); }); + it("keeps a fallback placeholder dimmer than entered text", () => { + const theme = parseVsCodeThemeFile({ + name: "Dark placeholder fallback", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + "editor.foreground": "#cdd6f4", + "input.placeholderForeground": "#cdd6f473", + }, + }); + + expect( + contrastRatio(theme.colors.placeholder, theme.colors.surfaceRaised), + ).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeGreaterThanOrEqual( + 4.5, + ); + expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeLessThan( + contrastRatio(theme.colors.text, theme.colors.canvas), + ); + }); + it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; From c0995d2eaf8ec787b3318ed1169ae266ed1529f8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 15:03:47 -0700 Subject: [PATCH 03/10] fix(web): keep the selected environment when changing projects (#9102) --- apps/web/src/environmentGrouping.test.ts | 68 ++++++++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 15 +++--- docs/user/composer.md | 5 ++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 9029f1204d36..9bd7a3e92428 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -323,6 +323,74 @@ describe("environment grouping", () => { expect(entries[1]?.group.displayName).toBe("separate"); }); + it("keeps the current environment when available and falls back otherwise", () => { + const currentPrimary = makeProject({ repositoryIdentity }); + const currentRemote = makeProject({ + id: ProjectId.make("current-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const destinationRepositoryIdentity = { + canonicalKey: "github.com/example/destination", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/destination.git", + }, + }; + const destinationPrimary = makeProject({ + id: ProjectId.make("destination-primary"), + title: "destination", + workspaceRoot: "/tmp/destination", + repositoryIdentity: destinationRepositoryIdentity, + }); + const destinationRemote = makeProject({ + id: ProjectId.make("destination-remote"), + environmentId: remoteEnvironmentId, + title: "destination", + workspaceRoot: "/remote/destination", + repositoryIdentity: destinationRepositoryIdentity, + }); + const fallbackPrimary = makeProject({ + id: ProjectId.make("fallback-primary"), + title: "fallback", + workspaceRoot: "/tmp/fallback", + }); + const groups = buildSidebarProjectSnapshots({ + projects: [ + currentPrimary, + currentRemote, + destinationPrimary, + destinationRemote, + fallbackPrimary, + ], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: remoteEnvironmentId, + projectId: currentRemote.id, + }, + }); + const destination = entries.find( + (entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey, + ); + const fallback = entries.find((entry) => entry.group.displayName === "fallback"); + + expect(destination?.targetProject).toMatchObject({ + environmentId: remoteEnvironmentId, + id: destinationRemote.id, + }); + expect(fallback?.targetProject).toMatchObject({ + environmentId: primaryEnvironmentId, + id: fallbackPrimary.id, + }); + }); + it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index be92fcaee849..8cf3c5665aca 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -118,22 +118,23 @@ export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; }) { + const preferredProjectRef = input.preferredProjectRef; const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { - const isPreferred = input.preferredProjectRef + const isPreferred = preferredProjectRef ? group.memberProjectRefs.some( (projectRef) => - projectRef.environmentId === input.preferredProjectRef?.environmentId && - projectRef.projectId === input.preferredProjectRef.projectId, + projectRef.environmentId === preferredProjectRef.environmentId && + projectRef.projectId === preferredProjectRef.projectId, ) : false; - const preferredProject = isPreferred + const preferredProject = preferredProjectRef ? (group.memberProjects.find( (project) => - project.environmentId === input.preferredProjectRef?.environmentId && - project.id === input.preferredProjectRef?.projectId, + project.environmentId === preferredProjectRef.environmentId && + project.id === preferredProjectRef.projectId, ) ?? group.memberProjects.find( - (project) => project.environmentId === input.preferredProjectRef?.environmentId, + (project) => project.environmentId === preferredProjectRef.environmentId, )) : null; const targetProject = diff --git a/docs/user/composer.md b/docs/user/composer.md index 86525f7e1869..97f1f5cb14f9 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -52,6 +52,11 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list when starting a thread or changing an existing thread's model. +## Changing projects + +On web and desktop, changing the project from a new thread keeps the current environment when that +project exists there. If it does not, T3 Code selects another environment that has the project. + ## Notices above the composer On web and desktop, loading and syncing statuses fill the available banner width beside the From 0222aa255d11babd242dbe5ed0947e5fc5eaefee Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:04:33 +0200 Subject: [PATCH 04/10] fix(web): preserve theme when toggling advanced colors (#8500) --- apps/web/src/components/settings/ThemeEditorPanel.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 0bb0d1b0ec18..9ac264cd00d3 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -308,6 +308,7 @@ export function ThemeEditorPanel({ const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< Record >({ light: false, dark: false }); + const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false); const [error, setError] = useState(null); const [isMinimized, setIsMinimized] = useState(false); const [roleQuery, setRoleQuery] = useState(""); @@ -401,6 +402,10 @@ export function ThemeEditorPanel({ // regenerate when the guided editor produced it. setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); setSimpleColorsDirtyByAppearance({ light: false, dark: false }); + // An unmanaged palette needs conversion when the user opts into the + // guided editor. Merely revealing Advanced for a managed/default draft + // must stay read-only until a color changes. + setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true); setColorsByAppearance(nextColors); setSelectedRole(null); setUsageCount(null); @@ -497,6 +502,7 @@ export function ThemeEditorPanel({ [activeAppearance]: true, })); } + if (isAdvanced) setShouldRegenerateGuidedColors(true); }, [activeAppearance, isAdvanced], ); @@ -735,6 +741,7 @@ export function ThemeEditorPanel({ if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) { setSelectedRole(null); } + if (!shouldRegenerateGuidedColors) return; // Regenerate every appearance the theme will save, not just the visible // one, so the palettes shown after toggling match what gets saved. @@ -754,8 +761,9 @@ export function ThemeEditorPanel({ } return next; }); + setShouldRegenerateGuidedColors(false); }, - [activeAppearance, editingTheme, selectedRole], + [activeAppearance, editingTheme, selectedRole, shouldRegenerateGuidedColors], ); const handleSubmit = () => { From 590a579f2e9292ce314c69e459e19620004578fe Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 18:06:21 -0400 Subject: [PATCH 05/10] fix(chat): keep latest command live between messages (#9098) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../chat/MessagesTimeline.logic.test.ts | 95 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 28 ++++-- .../components/chat/MessagesTimeline.test.tsx | 42 ++++++-- .../src/components/chat/MessagesTimeline.tsx | 13 ++- 4 files changed, 118 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d9bfdae04d49..d702d6ddda74 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -826,7 +826,7 @@ describe("deriveMessagesTimelineRows", () => { "assistant-final-entry", "user-followup-entry", "working-indicator-row", - "thinking-indicator-row", + "live-activity-row", ]); const finalRow = rows.find((row) => row.id === "assistant-final-entry"); expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); @@ -879,11 +879,11 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.id)).toEqual([ "working-indicator-row", "assistant-thought-entry", - "work-live:work-entry-1", + "live-activity-row", ]); }); - it("keeps adjacent active tool calls in one replacing row", () => { + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -948,6 +948,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ entry: { id: "running-command" }, + active: true, groupedEntries: [ { id: "running-command" }, { id: "completed-edit" }, @@ -1196,40 +1197,58 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("shows thinking after the latest tool call completes while the turn is running", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "latest-command-entry", - kind: "work", - createdAt: "2026-01-01T00:00:05Z", - entry: { - id: "latest-command", - createdAt: "2026-01-01T00:00:05Z", - turnId: "turn-1" as never, - label: "Ran rg", - command: "rg toolCall", - requestKind: "command", - tone: "tool" as const, - toolLifecycleStatus: "completed" as const, - }, + it("reuses one activity row for initial thinking and the latest tool", () => { + const deriveRows = (toolLifecycleStatus: "inProgress" | "completed" | "declined" | null) => + deriveMessagesTimelineRows({ + timelineEntries: + toolLifecycleStatus === null + ? [] + : [ + { + id: "latest-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "latest-command", + createdAt: "2026-01-01T00:00:05Z", + turnId: "turn-1" as never, + label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg", + command: "rg toolCall", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus, + }, + }, + ], + latestTurn: { + turnId: "turn-1" as never, + state: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, }, - ], - latestTurn: { - turnId: "turn-1" as never, - state: "running", - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); - expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); - expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); - expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); + const initialRows = deriveRows(null); + const runningRows = deriveRows("inProgress"); + const completedRows = deriveRows("completed"); + const declinedRows = deriveRows("declined"); + const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row"); + const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row"); + const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row"); + + expect(initialActivityRow).toMatchObject({ kind: "thinking" }); + expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true }); + expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false }); + expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); + expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1290,7 +1309,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("live-activity-row"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1587,8 +1606,8 @@ describe("computeStableMessagesTimelineRows", () => { initial, ); - const initialThinking = initial.byId.get("thinking-indicator-row"); - const updatedThinking = updated.byId.get("thinking-indicator-row"); + const initialThinking = initial.byId.get("live-activity-row"); + const updatedThinking = updated.byId.get("live-activity-row"); expect(initialThinking).toMatchObject({ kind: "thinking" }); expect(updatedThinking).toBe(initialThinking); expect(updated.result.at(-1)).toBe(updatedThinking); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c787446f738b..9f8794801854 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -15,6 +15,7 @@ export { import { formatDuration, workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -188,6 +189,8 @@ export type TimelineLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; +const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + export type MessagesTimelineRow = | { kind: "work"; @@ -598,21 +601,26 @@ export function deriveMessagesTimelineRows(input: { const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => workEntryIsActiveTurnActivity(entry.entry), ); - const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; + const latestToolKeepsActivityLive = + latestRunningToolEntry !== undefined || + (latestVisibleToolEntry !== undefined && + workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && displayedToolEntry + activeWorkAnchor && latestVisibleToolEntry ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { kind: "work-live" as const, - id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, + id: latestToolKeepsActivityLive + ? LIVE_ACTIVITY_ROW_ID + : `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`, createdAt: activeWorkAnchor.createdAt, - entry: displayedToolEntry.entry, + entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, - active: latestRunningToolEntry !== undefined, + active: latestToolKeepsActivityLive, }; })() : null; @@ -626,11 +634,11 @@ export function deriveMessagesTimelineRows(input: { createdAt: input.activeTurnStartedAt, }); }; - let hasLiveWorkRow = false; + let hasActivityRow = false; const appendActiveWorkRows = () => { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); - hasLiveWorkRow ||= activeWorkRow.active; + hasActivityRow ||= activeWorkRow.active; if (!activeWorkRow.expanded) return; for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { nextRows.push({ @@ -730,7 +738,7 @@ export function deriveMessagesTimelineRows(input: { expanded, active: true, }); - hasLiveWorkRow = true; + hasActivityRow = true; if (expanded) { for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { nextRows.push({ @@ -832,10 +840,10 @@ export function deriveMessagesTimelineRows(input: { if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { appendWorkingRow(); } - if (input.isWorking && !hasLiveWorkRow) { + if (input.isWorking && !hasActivityRow) { nextRows.push({ kind: "thinking", - id: "thinking-indicator-row", + id: LIVE_ACTIVITY_ROW_ID, createdAt: input.activeTurnStartedAt, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 024dfe69d278..5c6dbbf5b2e1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1308,7 +1308,30 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("keeps declined command copy visible while thinking continues", () => { + it("renders initial thinking as the shared live activity row", () => { + const turnId = TurnId.make("turn-live"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thinking"); + expect(markup).toContain("lucide-brain"); + expect(markup).toContain('data-timeline-row-id="live-activity-row"'); + }); + + it("keeps the completed command in the shared activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( { runningTurnId={turnId} timelineEntries={[ { - id: "entry-declined", + id: "entry-completed", kind: "work", createdAt: MESSAGE_CREATED_AT, entry: { - id: "work-declined", + id: "work-completed", createdAt: MESSAGE_CREATED_AT, turnId, - toolCallId: "call-declined", + toolCallId: "call-completed", label: "Run lint", tone: "tool", itemType: "command_execution", command: "pnpm lint", - toolLifecycleStatus: "declined", + toolLifecycleStatus: "completed", }, }, ]} />, ); - expect(markup).toContain("Declined pnpm"); - expect(markup).toContain("Thinking"); - expect(markup).toContain("tool call failed"); + expect(markup).toContain("Running pnpm"); + expect(markup).toContain("lucide-terminal"); + expect(markup).toContain("live-activity-focus"); + expect(markup).not.toContain("Ran pnpm"); + expect(markup).not.toContain("Thinking"); + expect(markup).not.toContain('data-timeline-row-kind="thinking"'); }); it("renders review comment contexts as structured cards instead of raw tags", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dbe8507aef80..df944a98485e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -62,6 +62,7 @@ import { import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; import { BotIcon, + BrainIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -985,14 +986,15 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time : "pb-0" : isExpandedToolGroupHeader ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" || row.kind === "thinking" + : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" : (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || row.kind === "work-live" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "thinking" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -1364,7 +1366,7 @@ function ThinkingTimelineRow() { // Reserve the activity row during setup so the handoff keeps the same height. return (
- {isPreparingWorktree ? null : } + {isPreparingWorktree ? null : }
); } @@ -2123,6 +2125,7 @@ function formatWorkingTimerNow(startIso: string): string { type WorkEntryIconName = | "bot" + | "brain" | "check" | "circle-alert" | "eye" @@ -2140,6 +2143,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN switch (name) { case "bot": return ; + case "brain": + return ; case "check": return ; case "circle-alert": @@ -2179,7 +2184,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { } if (tone === "thinking") { return { - iconName: "bot", + iconName: "brain", className: "text-foreground", }; } From 60cef47ec983637ddc68faed7b1488b6f3c3a175 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:14:49 +0000 Subject: [PATCH 06/10] chore(release): prepare v0.0.38 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b4d6e8d73958..83a07cccb660 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.37", + "version": "0.0.38", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 3f7ae6096461..073008d917a5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.37", + "version": "0.0.38", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 21e051c4bce6..0a9723e19129 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.37", + "version": "0.0.38", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c0d916a0baae..dbd8ee3744e8 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.37", + "version": "0.0.38", "private": true, "files": [ "dist" From beae2147a9487ec47ac992319f2216914b4cb62d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 16:05:15 -0700 Subject: [PATCH 07/10] fix(media): preview host files and stream videos across clients (#9023) --- .../src/electron/ElectronProtocol.test.ts | 2 +- apps/desktop/src/electron/ElectronProtocol.ts | 4 +- .../assets/file-icons/pierre_video.png | Bin 0 -> 676 bytes .../modules/t3-markdown-text/package.json | 2 + .../scripts/sync-pierre-file-icons.mjs | 2 + .../src/markdownFileIcons.generated.ts | 1 + .../t3-markdown-text/src/markdownLinks.ts | 31 +- .../src/nativeMarkdownText.ts | 19 +- .../mobile/src/components/FilePreview.ios.tsx | 14 +- apps/mobile/src/components/FilePreview.tsx | 2 + .../src/components/FilePreviewModal.tsx | 16 +- .../src/components/MediaActionsMenu.tsx | 43 ++ .../src/components/MediaImagePreview.tsx | 61 +++ .../src/components/MediaSourceCaption.tsx | 19 + .../src/components/MediaVideoPlayer.tsx | 187 ++++++++ .../src/components/MediaVideoPreviewModal.tsx | 96 ++++ .../src/components/VideoPreviewModal.ios.tsx | 10 +- .../src/components/VideoPreviewModal.tsx | 18 +- .../src/components/VideoThumbnailImage.tsx | 3 +- .../features/files/ThreadFilesRouteScreen.tsx | 135 +++++- .../files/WorkspaceFileImagePreview.tsx | 14 +- .../files/WorkspaceFileVideoPreview.tsx | 47 ++ apps/mobile/src/features/files/filePath.ts | 5 + .../features/files/preload-workspace-file.ts | 8 +- .../features/files/workspaceFileAssetUrl.ts | 30 +- .../src/features/threads/ThreadFeed.tsx | 358 +++++++++++---- apps/mobile/src/lib/markdownLinks.test.ts | 29 +- apps/mobile/src/lib/markdownMedia.test.ts | 77 ++++ apps/mobile/src/lib/markdownMedia.ts | 89 ++++ apps/mobile/src/lib/mediaActions.ts | 121 +++++ .../mobile/src/lib/nativeMarkdownText.test.ts | 37 ++ apps/mobile/src/lib/videoPreviewSource.ts | 54 +++ apps/mobile/src/state/assets.ts | 22 + .../mobile/src/state/use-atom-query-runner.ts | 8 +- apps/server/src/assets/AssetAccess.test.ts | 335 ++++++++++++++ apps/server/src/assets/AssetAccess.ts | 84 ++++ apps/server/src/assets/MediaFile.ts | 113 +++++ apps/server/src/http.test.ts | 176 +++++++- apps/server/src/http.ts | 56 ++- apps/web/src/assets/assetUrls.ts | 19 +- apps/web/src/components/ChatMarkdown.tsx | 417 +++++++++++++++--- .../ChatMarkdown.workspace-images.test.tsx | 13 +- .../web/src/components/ChatView.logic.test.ts | 18 - apps/web/src/components/ChatView.logic.ts | 39 +- apps/web/src/components/ChatView.tsx | 100 ++--- apps/web/src/components/chat/ChatComposer.tsx | 5 +- .../chat/ExpandedImageDialog.test.tsx | 28 -- .../components/chat/ExpandedImageDialog.tsx | 177 ++++---- .../chat/ExpandedImagePreview.test.ts | 63 +-- .../components/chat/ExpandedImagePreview.tsx | 121 ++++- .../src/components/chat/MessagesTimeline.tsx | 1 + .../chat/externalLinkContextMenu.test.ts | 2 + .../chat/externalLinkContextMenu.ts | 2 +- .../src/components/files/FilePreviewPanel.tsx | 138 +++++- .../files/projectFilesQueryState.ts | 14 +- .../web/src/components/media/MediaActions.tsx | 189 ++++++++ .../src/components/media/MediaVideoPlayer.tsx | 197 +++++++++ .../src/components/media/OpenMediaLink.tsx | 45 ++ apps/web/src/components/media/mediaContent.ts | 82 ++++ .../pullRequest/PullRequestMarkdown.tsx | 6 +- apps/web/src/contextMenuFallback.ts | 4 + apps/web/src/lib/videoFirstFrame.test.ts | 83 ++++ apps/web/src/lib/videoFirstFrame.ts | 28 ++ apps/web/src/markdown-links.test.ts | 1 + apps/web/src/markdown-links.ts | 160 +------ apps/web/src/pierre-icons.ts | 11 + apps/web/src/state/use-atom-query-runner.ts | 8 +- docs/internals/environment-auth.md | 34 ++ docs/user/composer.md | 44 +- packages/client-runtime/package.json | 8 + packages/client-runtime/src/markdownImages.ts | 2 +- .../client-runtime/src/markdownLinks.test.ts | 30 ++ packages/client-runtime/src/markdownLinks.ts | 158 +++++++ .../client-runtime/src/mediaReference.test.ts | 39 ++ packages/client-runtime/src/mediaReference.ts | 95 ++++ .../src/work-log/presentation.test.ts | 2 +- .../src/work-log/presentation.ts | 4 +- packages/contracts/src/assets.ts | 16 +- packages/shared/src/filePreview.test.ts | 27 ++ packages/shared/src/filePreview.ts | 58 +++ packages/shared/src/video.ts | 2 + pnpm-lock.yaml | 8 +- 82 files changed, 4144 insertions(+), 682 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png create mode 100644 apps/mobile/src/components/MediaActionsMenu.tsx create mode 100644 apps/mobile/src/components/MediaImagePreview.tsx create mode 100644 apps/mobile/src/components/MediaSourceCaption.tsx create mode 100644 apps/mobile/src/components/MediaVideoPlayer.tsx create mode 100644 apps/mobile/src/components/MediaVideoPreviewModal.tsx create mode 100644 apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx create mode 100644 apps/mobile/src/lib/markdownMedia.test.ts create mode 100644 apps/mobile/src/lib/markdownMedia.ts create mode 100644 apps/mobile/src/lib/mediaActions.ts create mode 100644 apps/mobile/src/lib/videoPreviewSource.ts create mode 100644 apps/server/src/assets/MediaFile.ts delete mode 100644 apps/web/src/components/chat/ExpandedImageDialog.test.tsx create mode 100644 apps/web/src/components/media/MediaActions.tsx create mode 100644 apps/web/src/components/media/MediaVideoPlayer.tsx create mode 100644 apps/web/src/components/media/OpenMediaLink.tsx create mode 100644 apps/web/src/components/media/mediaContent.ts create mode 100644 apps/web/src/lib/videoFirstFrame.test.ts create mode 100644 apps/web/src/lib/videoFirstFrame.ts create mode 100644 packages/client-runtime/src/markdownLinks.test.ts create mode 100644 packages/client-runtime/src/markdownLinks.ts create mode 100644 packages/client-runtime/src/mediaReference.test.ts create mode 100644 packages/client-runtime/src/mediaReference.ts diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 585dd7e7ea1e..a5c03e0b9336 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -225,7 +225,7 @@ describe("ElectronProtocol", () => { "http:", "https:", ]); - assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:"]); + assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 845f03dba5da..fabd598d7ffa 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -87,7 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat `script-src ${scriptSources.join(" ")}`, `connect-src ${connectSources.join(" ")}`, `img-src 'self' ${input.scheme}: blob: data: http: https:`, - `media-src 'self' ${input.scheme}: blob:`, + `media-src 'self' ${input.scheme}: blob: http: https:`, "style-src 'self' 'unsafe-inline'", `font-src 'self' ${input.scheme}: data:`, "worker-src 'self' blob:", @@ -118,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, { @@ -127,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, ]); diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png new file mode 100644 index 0000000000000000000000000000000000000000..673c95d8b36a84ea316c04840d0018b0d3101175 GIT binary patch literal 676 zcmV;V0$crwP)_cp%VRh3TQQO00pm@%`VxUu0P4A9j!gzcs!mNPnJ0x4u|7$(B{WA0^K8! z14bR*7lO$b0{zj)$&69Y=D$}cXF*5M6?8tdLk<{p_oy>n&8kB0_2dPC3<%|#QU9hO z!B#@$;rjS_sm>2J#$5*aU3Z5TmSB756P6kIKeczF3KDR%QkVb&8PslkMM9`lJm6u@ zoCmp>3qFQY`CNSfy!ofq!Mh-c@-?T;Reyc*Dw+TW-8Gp6-Fwj1QrMNPLX5}Q)Riqu z*L*ud-Om}6H}T3)4vo&A+ijagK!D{5VTp`ZMxZB#>widvfMBaJfG6?F(45=DWC$o= zzX>M*-2;Y36urz@qH3K2IZNhcsVP9Hqf*JoatVk{hDPzqP{03g1cW|00kI&T0^05% z$i57!XM*ghu=-wreKVxK8{|cnL3n~AvD<$pvKPSS$#VAs*hpF8UH}s(fP4yQyMrKm zDy*IgbEFHIZt2J3;`ahs#qf)fZtXWi(w-wR@)aq^0E-)gvuo|uf_<0^s=I09IYh%R^ZNKfA%(}&k7R|5FQ((Oag)aTKK`leNPDW z_9;D?xs6|Pyzt0d2=(@%") ? trimmed.slice(1, -1) : trimmed; } +/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ +export function normalizeNativeMarkdownUrl(value: string): string { + return value.startsWith("//") ? `https:${value}` : value; +} + function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") { return null; } - const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) - ? parsed.pathname.slice(1) + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const rawPath = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` : parsed.pathname; + const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; return { path, hash: parsed.hash }; } catch { return null; @@ -327,6 +340,7 @@ function looksLikeFilePath(value: string): boolean { if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { return true; } + if (isConventionalFilePosition(value)) return true; return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); } @@ -338,6 +352,7 @@ function fileLabel(value: string): string { export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { @@ -354,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { const normalized = normalizeDestination(href); try { - const parsed = new URL(normalized); + const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); if (parsed.protocol === "http:" || parsed.protocol === "https:") { return { kind: "external", @@ -399,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, }; } + +/** Backticks become file references only when the shared path heuristic recognizes the whole span. */ +export function resolveMarkdownInlineCodePresentation( + content: string, +): Extract | null { + const candidate = inlineCodeFilePathCandidate(content); + if (candidate === null) return null; + const presentation = resolveMarkdownLinkPresentation(candidate); + return presentation.kind === "file" ? presentation : null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 8db904b5a6ca..2b39ac201599 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,7 +1,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; -import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; +import { + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, + type MarkdownFileIcon, +} from "./markdownLinks"; export interface NativeMarkdownTextRun { readonly text: string; @@ -283,8 +287,17 @@ function appendNode( return appendRun(runs, textNodeContent(nodeTextContent(node)), context); case "html_inline": return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); - case "code_inline": - return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "code_inline": { + const content = nodeTextContent(node); + const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); + return presentation + ? appendRun(runs, presentation.label, { + ...context, + href: presentation.href, + fileIcon: presentation.icon, + }) + : appendRun(runs, content, { ...context, code: true }); + } case "soft_break": return appendRun(runs, " ", context); case "line_break": diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index c7740104fe6a..c2f5a6d72cc7 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -3,6 +3,7 @@ import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; const NativeControls = requireNativeModule<{ presentFile( @@ -14,7 +15,7 @@ const NativeControls = requireNativeModule<{ dismissFile(identifier: string): Promise; }>("T3NativeControls"); -export function FilePreview(props: { +function NativeFilePreview(props: { readonly source: ResolvedFilePreviewSource; readonly onRequestClose: () => void; }) { @@ -41,3 +42,14 @@ export function FilePreview(props: { return null; } + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + return props.source.kind === "image" && props.source.actionsSource ? ( + + ) : ( + + ); +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index f10bfb8b3e63..8240c4a6ad38 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -4,6 +4,7 @@ import ImageViewing from "react-native-image-viewing"; import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; function PdfPreview(props: { readonly source: ResolvedFilePreviewSource; @@ -39,6 +40,7 @@ export function FilePreview(props: { readonly onRequestClose: () => void; }) { if (props.source.kind === "pdf") return ; + if (props.source.actionsSource) return ; return ( & @@ -40,13 +43,18 @@ function ResolvedFilePreview(props: { (connection._tag === "None" || asset._tag === "Failure"); useEffect(() => Keyboard.dismiss(), []); useEffect(() => { - if (uri === null && asset._tag === "Success") setUri(asset.url); - }, [uri, asset]); + if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? "")); + }, [uri, asset, source.srcFragment]); useEffect(() => { if (!failed) return; - Alert.alert("Could not open preview", "Reconnect to this environment and try again."); + Alert.alert( + "Could not open preview", + connection._tag === "None" + ? "Reconnect to this environment and try again." + : "The file could not be loaded. It may have been moved or deleted.", + ); onRequestClose(); - }, [failed]); + }, [failed, connection._tag]); useEffect(() => { if (!("attachment" in source)) return; const controller = new AbortController(); diff --git a/apps/mobile/src/components/MediaActionsMenu.tsx b/apps/mobile/src/components/MediaActionsMenu.tsx new file mode 100644 index 000000000000..a4b44e85258f --- /dev/null +++ b/apps/mobile/src/components/MediaActionsMenu.tsx @@ -0,0 +1,43 @@ +import { MenuView } from "@react-native-menu/menu"; +import type { ReactElement } from "react"; +import { Platform, View, type PressableProps } from "react-native"; + +import type { useMediaActions } from "../lib/mediaActions"; +import { SymbolView } from "./AppSymbol"; +import { ControlPillMenu } from "./ControlPill"; + +export function MediaActionsMenu(props: { + readonly media: ReturnType; + readonly inModal?: boolean; + readonly children?: ReactElement; +}) { + if (props.media.actions.length === 0) return props.children ?? null; + // Android's normal anchored menu lives in the app-root portal, behind native modals. + const nativeAndroidMenu = props.inModal && Platform.OS === "android"; + const Menu = nativeAndroidMenu ? MenuView : ControlPillMenu; + return ( + ({ + id, + title, + attributes: { disabled: disabled ?? false }, + }))} + onPressAction={({ nativeEvent }) => { + props.media.actions.find(({ id }) => id === nativeEvent.event)?.run(); + }} + > + {props.children ?? ( + + + + )} + + ); +} diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx new file mode 100644 index 000000000000..5bdc9140ddc9 --- /dev/null +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext } from "react"; +import { Pressable, View } from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +type MediaImagePreviewProps = { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}; + +const ImagePreviewContext = createContext(null); + +function ImagePreviewHeader() { + const props = useContext(ImagePreviewContext)!; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(props.source.actionsSource, props.onRequestClose); + return ( + + + + {props.source.name ?? "Image"} + + + + + + + + + ); +} + +/** Chat and workspace media retain source actions on both platforms; other files use native previews. */ +export function MediaImagePreview(props: MediaImagePreviewProps) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/MediaSourceCaption.tsx b/apps/mobile/src/components/MediaSourceCaption.tsx new file mode 100644 index 000000000000..76c24290d347 --- /dev/null +++ b/apps/mobile/src/components/MediaSourceCaption.tsx @@ -0,0 +1,19 @@ +import { ScrollView } from "react-native"; + +import { AppText } from "./AppText"; + +/** Keep the original reference readable without letting long URLs displace the preview. */ +export function MediaSourceCaption(props: { readonly source: string | undefined }) { + if (!props.source) return null; + return ( + + + {props.source} + + + ); +} diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx new file mode 100644 index 000000000000..a065f75e1396 --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -0,0 +1,187 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEvent } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { ActivityIndicator, AppState, Pressable, View } from "react-native"; + +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { VideoThumbnailImage } from "./VideoThumbnailImage"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; + +/** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ +function LoadedMediaVideo(props: { + readonly uri: string; + readonly resolvePlaybackUri?: () => Promise; + readonly playRequested: boolean; + readonly paused: boolean; +}) { + const focused = useIsFocused(); + const active = useRef(focused && AppState.currentState === "active"); + const [attempt, setAttempt] = useState(0); + // Expo's Android player also reports completed playback as idle. + const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); + const player = useVideoPlayer(null, (player) => { + player.staysActiveInBackground = false; + player.bufferOptions = { preferredForwardBufferDuration: 5 }; + }); + const { status } = useEvent(player, "statusChange", { status: player.status }); + const loadSource = useEffectEvent(async (signal: AbortSignal) => { + const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; + if (signal.aborted) return; + if (uri === null) throw new Error("Video unavailable"); + player.pause(); + await player.replaceAsync({ uri, contentType: "progressive" }); + if (!signal.aborted && props.playRequested && active.current) player.play(); + }); + + useEffect(() => { + active.current = focused && !props.paused && AppState.currentState === "active"; + if (!active.current) player.pause(); + const subscription = AppState.addEventListener("change", (state) => { + active.current = focused && !props.paused && state === "active"; + if (!active.current) player.pause(); + }); + return () => subscription.remove(); + }, [focused, player, props.paused]); + + useEffect(() => { + const controller = new AbortController(); + setLoadState("pending"); + // A renewed signature is used on Retry, not as a reason to reset the native player. + void loadSource(controller.signal).then( + () => { + if (!controller.signal.aborted) setLoadState("complete"); + }, + () => { + if (!controller.signal.aborted) setLoadState("error"); + }, + ); + return () => controller.abort(); + }, [player, props.playRequested, attempt]); + + return ( + + + {loadState === "error" || (loadState === "complete" && status === "error") ? ( + + Video unavailable + setAttempt((value) => value + 1)} + className="min-h-11 justify-center px-4" + > + Retry + + + ) : loadState === "pending" || status === "loading" ? ( + + + + ) : null} + + ); +} + +interface MediaVideoPlayerProps { + readonly uri: string | null; + readonly resolvePlaybackUri?: () => Promise; + readonly name: string; + readonly thumbnailKey: string; + readonly thumbnailVisible?: boolean; + readonly unavailable?: boolean; + readonly expanded?: boolean; + readonly paused?: boolean; + readonly onExpand?: () => void; + readonly actionsSource?: MediaActionsSource; +} + +function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { + const mediaActions = useMediaActions(props.actionsSource); + const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); + // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. + if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + + return ( + + {playbackUri ? ( + + ) : ( + setPlaybackUri(props.uri)} + className="flex-1 items-center justify-center gap-2 px-4" + > + {!props.unavailable ? ( + + ) : null} + {props.unavailable ? ( + Video unavailable + ) : props.uri === null ? ( + + ) : ( + <> + + + + + {props.name} + + + )} + + )} + {props.onExpand ? ( + { + setPlaybackUri(null); + props.onExpand?.(); + }} + className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" + > + Expand + + ) : null} + {props.actionsSource ? ( + + + + ) : null} + + ); +} + +export function MediaVideoPlayer(props: MediaVideoPlayerProps) { + return ; +} diff --git a/apps/mobile/src/components/MediaVideoPreviewModal.tsx b/apps/mobile/src/components/MediaVideoPreviewModal.tsx new file mode 100644 index 000000000000..6c231194701c --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPreviewModal.tsx @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { Keyboard, Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { MediaVideoPlayer } from "./MediaVideoPlayer"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +/** Media files stream in place. A client-side copy is made only for an explicit share. */ +export function MediaVideoPreviewModal(props: { + readonly source: MediaVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const insets = useSafeAreaInsets(); + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + const refreshAssetUrl = useRefreshAssetUrl( + environmentId, + "resource" in source ? source.resource : null, + ); + const resolvePlaybackUri = + "resource" in source + ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined; + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); + const unavailable = + uri === null && + environmentId !== null && + (connection._tag === "None" || asset._tag === "Failure"); + + useEffect(() => Keyboard.dismiss(), []); + return ( + + + + + {source.name} + + + + + + + + + + + {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} + + + + + ); +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx index a947d8d2e51c..88a1b5191dd0 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -7,9 +7,10 @@ import { Alert, Keyboard } from "react-native"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; -import type { VideoPreviewSource } from "./VideoPreviewModal"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; -export type { VideoPreviewSource } from "./VideoPreviewModal"; +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; const NativeControls = requireNativeModule<{ presentVideo( @@ -22,7 +23,7 @@ const NativeControls = requireNativeModule<{ }>("T3NativeControls"); function NativeVideoPreview(props: { - readonly source: VideoPreviewSource; + readonly source: AttachmentVideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; @@ -117,5 +118,8 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource]); if (!props.source || !isFocused) return null; + if (props.source.type === "media") { + return ; + } return ; } diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index eaa01c5d1714..cc56b3952b75 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -1,5 +1,4 @@ import { useIsFocused } from "@react-navigation/native"; -import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; @@ -19,21 +18,15 @@ import { downloadAttachmentForPreview, type AttachmentPreviewFile, } from "../lib/attachmentDownload"; -import type { DraftComposerFileAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; import { useAssetUrlState } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { SymbolView } from "./AppSymbol"; import { AppText } from "./AppText"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; -export type VideoPreviewSource = ( - | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } - | { - readonly type: "remote"; - readonly environmentId: EnvironmentId; - readonly attachment: ChatFileAttachment; - } -) & { readonly sourceIdentifier?: string }; +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { const player = useVideoPlayer(props.file.uri, (player) => { @@ -120,7 +113,7 @@ function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { } function OpenVideoPreviewModal(props: { - readonly source: VideoPreviewSource; + readonly source: AttachmentVideoPreviewSource; readonly onRequestClose: () => void; }) { const { source } = props; @@ -250,6 +243,9 @@ export function VideoPreviewModal(props: { }, [isFocused, hasSource, props.onRequestClose]); const { source } = props; if (source === null || !isFocused) return null; + if (source.type === "media") { + return ; + } const key = source.type === "local" ? `local:${source.attachment.id}:${source.attachment.fileUri}` diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index 0be94c700ce6..cfb8ceeb2aca 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -11,6 +11,7 @@ import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails export function VideoThumbnailImage(props: { readonly cacheKey: string; readonly source: string | DraftComposerFileAttachment | null; + readonly contentFit?: "cover" | "contain"; }) { const { cacheKey, source } = props; const isFocused = useIsFocused(); @@ -37,7 +38,7 @@ export function VideoThumbnailImage(props: { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 5dddac1dd820..052c89a7e623 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,7 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { MenuAction } from "@react-native-menu/menu"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -11,6 +11,9 @@ import { type ProjectReadFileResult, ThreadId, } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; +import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -24,6 +27,8 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; @@ -47,6 +52,7 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; +import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, @@ -54,8 +60,9 @@ import { isImagePreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, + isVideoPreviewFile, } from "./filePath"; -import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; +import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; type FileViewMode = "preview" | "source"; @@ -84,7 +91,8 @@ function normalizeRouteLine(value: string | null): number | null { } function defaultViewMode(path: string | null): FileViewMode { - return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path)) + return path !== null && + (isBrowserPreviewFile(path) || isImagePreviewFile(path) || isVideoPreviewFile(path)) ? "preview" : "source"; } @@ -92,6 +100,10 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; readonly previewUri: string | null; + readonly previewUnavailable: boolean; + readonly videoSource: MediaVideoPreviewSource | null; + readonly mediaSource?: MediaActionsSource; + readonly resolveVideoUri: () => Promise; readonly fileContents: string | null; readonly fileError: string | null; readonly relativePath: string; @@ -99,10 +111,25 @@ function FileContent(props: { readonly truncated: boolean; readonly onRefresh?: () => Promise | void; }) { + // Reopening a mutable host file must not reuse a poster from an earlier visit. + const thumbnailInstanceId = useId(); const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isBrowserPreviewFile(props.relativePath); const isImageFile = isImagePreviewFile(props.relativePath); + if (isVideoPreviewFile(props.relativePath)) { + return ( + + ); + } + if (props.activeMode === "preview" && isImageFile) { if (isSvgImagePreviewFile(props.relativePath)) { return ; @@ -111,6 +138,7 @@ function FileContent(props: { ); } @@ -489,29 +517,73 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly mode: FileViewMode; } | null>(null); const [previewRevision, setPreviewRevision] = useState(0); + const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); - const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); - const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); + const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); + const isBrowserFile = relativePath !== null && !isVideoFile && isBrowserPreviewFile(relativePath); + const isImageFile = relativePath !== null && !isVideoFile && isImagePreviewFile(relativePath); const canPreview = - relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); + relativePath !== null && + (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile); const activeMode = relativePath !== null && modeOverride?.path === relativePath ? modeOverride.mode : defaultViewMode(relativePath); - const resolvedActiveMode = canPreview ? activeMode : "source"; - const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; - const assetPreviewUri = useWorkspaceFileAssetUrl({ + const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source"; + const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null; + const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, relativePath: assetPreviewPath, threadId, }); + const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null; + const mediaSource = useMemo( + () => + environmentId !== null && + threadId !== null && + relativePath !== null && + assetPreview.resource !== null && + "path" in assetPreview.resource && + typeof assetPreview.resource.path === "string" && + (isImageFile || isVideoFile) + ? { + reference: mediaFileReference(assetPreview.resource.path, cwd), + name: basename(relativePath), + mimeType: + mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? + "application/octet-stream", + environmentId, + threadId, + resource: assetPreview.resource, + } + : undefined, + [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId], + ); + const mediaActions = useMediaActions(mediaSource); + const videoSource = useMemo( + () => + environmentId !== null && + relativePath !== null && + assetPreview.resource?._tag === "media-file" + ? { + type: "media", + environmentId, + resource: assetPreview.resource, + name: basename(relativePath), + mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4", + actionsSource: mediaSource, + } + : null, + [assetPreview.resource, environmentId, relativePath, mediaSource], + ); const previewUri = assetPreviewUri === null || previewRevision === 0 ? assetPreviewUri : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; const needsFileContents = relativePath !== null && + !isVideoFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); const fileQuery = useEnvironmentQuery( environmentId !== null && cwd !== null && relativePath !== null && needsFileContents @@ -562,7 +634,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const fileMenuActions = useMemo(() => { if (relativePath === null) return []; - const canToggleMode = canPreview && !isImageFile; + const canToggleMode = canPreview && !isImageFile && !isVideoFile; return [ canToggleMode ? ({ @@ -582,13 +654,26 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => setModeOverride({ path: relativePath, mode: "source" }), } as const) : null, - { - id: "copy-path", - title: "Copy path", - icon: "doc.on.doc", - inline: false, - onPress: () => copyTextWithHaptic(relativePath), - } as const, + ...(mediaSource + ? mediaActions.actions + .filter(({ id }) => id !== "open-file") + .map((action) => ({ + id: action.id, + title: action.title, + icon: + action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), + inline: false, + onPress: action.run, + })) + : [ + { + id: "copy-path", + title: "Copy path", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(relativePath), + } as const, + ]), isPdfFile({ name: relativePath }) && previewUri !== null ? ({ id: "open-pdf", @@ -612,24 +697,31 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), } as const) : null, - resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) + resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile) ? ({ id: "refresh", title: "Refresh", icon: "arrow.clockwise", inline: false, - onPress: () => setPreviewRevision((current) => current + 1), + onPress: async () => { + if (isVideoFile) await assetPreview.refresh(); + setPreviewRevision((current) => current + 1); + }, } as const) : null, ].filter((action) => action !== null); }, [ assetPreviewUri, + assetPreview.refresh, previewUri, canPreview, isBrowserFile, isImageFile, + isVideoFile, relativePath, resolvedActiveMode, + mediaSource, + mediaActions.actions, ]); const androidFileMenuActions = useMemo( @@ -782,8 +874,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { (null); const [preview, setPreview] = useState(null); const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], @@ -34,6 +38,7 @@ function ResolvedWorkspaceFileImagePreview(props: { uri: props.uri, name: props.accessibilityLabel, sourceIdentifier, + actionsSource: props.actionsSource, }) } > @@ -50,13 +55,16 @@ function ResolvedWorkspaceFileImagePreview(props: { /> - {loadError !== null ? ( ) : null} + + + + setPreview(null)} /> ); @@ -65,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: { function CachedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; + readonly actionsSource?: MediaActionsSource; }) { const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); const imageResult = useAtomValue(imageAtom); @@ -93,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: { ); } @@ -100,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: { export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string | null; + readonly actionsSource?: MediaActionsSource; }) { if (props.uri === null) { return ( @@ -116,6 +127,7 @@ export function WorkspaceFileImagePreview(props: { ); } diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx new file mode 100644 index 000000000000..aaa13427fac0 --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { View } from "react-native"; + +import { EmptyState } from "../../components/EmptyState"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; + +/** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ +export function WorkspaceFileVideoPreview(props: { + readonly name: string; + readonly thumbnailKey: string; + readonly uri: string | null; + readonly source: MediaVideoPreviewSource | null; + readonly resolvePlaybackUri: () => Promise; + readonly unavailable: boolean; +}) { + const [preview, setPreview] = useState(null); + const uri = props.uri; + + if (props.unavailable) { + return ( + + + + ); + } + + return ( + + setPreview(props.source) + } + /> + setPreview(null)} /> + + ); +} diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 385d5c139eea..12217aab74f1 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,6 +1,7 @@ import { isWorkspaceBrowserPreviewPath, isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, } from "@t3tools/shared/filePreview"; export interface FileBreadcrumb { @@ -95,6 +96,10 @@ export function isImagePreviewFile(path: string): boolean { return isWorkspaceImagePreviewPath(path); } +export function isVideoPreviewFile(path: string): boolean { + return isWorkspaceVideoPreviewPath(path); +} + export function isSvgImagePreviewFile(path: string): boolean { return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts index b9e21cfd98f9..7df750883f52 100644 --- a/apps/mobile/src/features/files/preload-workspace-file.ts +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; -import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath"; +import { isBrowserPreviewFile, isImagePreviewFile, isVideoPreviewFile } from "./filePath"; import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; @@ -25,7 +25,11 @@ export function preloadWorkspaceFileContents(input: { readonly relativePath: string; readonly theme: ReviewDiffTheme; }): void { - if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) { + if ( + isBrowserPreviewFile(input.relativePath) || + isImagePreviewFile(input.relativePath) || + isVideoPreviewFile(input.relativePath) + ) { return; } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index 70ea3e43582b..eb2ba93b4979 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -1,10 +1,10 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { useAssetUrl } from "../../state/assets"; -import { resolveWorkspaceFilePath } from "./filePath"; +import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; +import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; -export function useWorkspaceFileAssetUrl(props: { +export function useWorkspaceFileAssetUrlState(props: { readonly cwd: string | null; readonly environmentId: EnvironmentId | null; readonly relativePath: string | null; @@ -18,14 +18,18 @@ export function useWorkspaceFileAssetUrl(props: { [props.cwd, props.relativePath], ); - return useAssetUrl( - props.environmentId, - absolutePath !== null && props.threadId !== null - ? { - _tag: "workspace-file", - threadId: props.threadId, - path: absolutePath, - } - : null, + const resource = useMemo( + () => + absolutePath !== null && props.threadId !== null + ? { + _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", + threadId: props.threadId, + path: absolutePath, + } + : null, + [absolutePath, props.threadId], ); + const state = useAssetUrlState(props.environmentId, resource); + const refresh = useRefreshAssetUrl(props.environmentId, resource); + return { ...state, resource, refresh }; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index cb72aa1f653e..3cbf02efa2ba 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,6 +1,6 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; -import { type LegendListRef } from "@legendapp/list/react-native"; +import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; import type { AssetResource, ChatAttachment, @@ -33,6 +33,7 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { + createContext, memo, useCallback, useContext, @@ -97,6 +98,15 @@ import { import { AppText as Text } from "../../components/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; +import { MediaActionsMenu } from "../../components/MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, @@ -123,7 +133,11 @@ import { import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import { + normalizeNativeMarkdownUrl, + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, +} from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, type ThreadFeedEntry, @@ -143,7 +157,12 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { + assetEnvironment, + useAssetUrl, + useAssetUrlState, + useRefreshAssetUrl, +} from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; @@ -272,6 +291,7 @@ function MessageAttachmentFile(props: { }) { const sourceIdentifier = useId(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, reportFailure: false, }); const preparedConnection = usePreparedConnection(props.environmentId); @@ -453,9 +473,11 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; + readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -498,36 +520,51 @@ function ThreadMarkdownImageView(props: { ) : ( )} + {props.actionsSource ? ( + + + + ) : null} ) : ( - - props.onPressPreview({ - kind: "image", - uri: props.uri!, - name: props.alt ?? "Image", - sourceIdentifier, - }) - } - style={{ alignSelf: "flex-start" }} - > - - setFailedUri(props.uri)} - /> - - + + + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + + {props.actionsSource ? ( + + + + ) : null} + )} {props.alt ? ( @@ -574,9 +611,10 @@ function ThreadMarkdownImageRequest(props: { /** Environment-hosted image that loads through a signed asset URL. */ function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract; readonly alt: string | null; readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); @@ -591,11 +629,59 @@ function ThreadMarkdownImage(props: { } unavailable={assetUrl._tag === "Failure"} alt={props.alt} + actionsSource={props.actionsSource} onPressPreview={props.onPressPreview} /> ); } +const ThreadMediaVisibleContext = createContext(false); +// LegendList only computes hook visibility when the list has a viewability config. +const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; + +function ThreadMediaVisibility(props: { readonly children: ReactNode }) { + const [visible, setVisible] = useState(false); + useViewabilityAmount( + useCallback((token) => setVisible(token.sizeVisible > 0), []), + ); + return {props.children}; +} + +function ThreadMarkdownVideo(props: { + readonly source: MediaVideoPreviewSource; + readonly onExpand: (source: MediaVideoPreviewSource) => void; +}) { + const { source } = props; + const visible = useContext(ThreadMediaVisibleContext); + const thumbnailKey = mediaVideoThumbnailKey(source); + const asset = useAssetUrlState( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const refreshAssetUrl = useRefreshAssetUrl( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return ( + mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined + } + name={source.name} + thumbnailKey={thumbnailKey} + thumbnailVisible={visible} + unavailable={"resource" in source && asset._tag === "Failure"} + actionsSource={source.actionsSource} + onExpand={() => props.onExpand(source)} + /> + ); +} + function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { return ( (); +const MarkdownLinkLabelContext = createContext(false); const markdownLinkStyles = StyleSheet.create({ inlineIcon: { width: 14, @@ -653,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly color: string; readonly host: string; readonly href: string; + readonly onPress: (href: string) => void; }) { const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); return ( { - void tryOpenExternalUrl(props.href, "markdown-link"); - }} + onPress={() => props.onPress(props.href)} style={{ color: props.color, textDecorationLine: "none", @@ -686,6 +772,37 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { ); }); +function MarkdownInlineCode(props: { + readonly content: string; + readonly textColor: string; + readonly codeColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly onLinkPress: (href: string) => void; +}) { + const insideLink = useContext(MarkdownLinkLabelContext); + const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); + return ( + props.onLinkPress(presentation.href) : undefined} + style={{ + color: presentation ? props.textColor : props.codeColor, + fontSize: props.fontSize, + lineHeight: props.lineHeight, + }} + > + {presentation ? ( + + ) : null} + {presentation?.label ?? props.content} + + ); +} + const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName @@ -1078,30 +1195,35 @@ function useMarkdownStyles( } if (presentation.kind === "external") { return ( - - {children} - + + + {children} + + ); } const linkHref = presentation.href; return ( - { - void tryOpenExternalUrl(linkHref, "markdown-link"); - } - : undefined - } - style={{ color: markdownLinkColor }} - > - {children} - + + { + void tryOpenExternalUrl(linkHref, "markdown-link"); + } + : undefined + } + style={{ color: markdownLinkColor }} + > + {children} + + ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( @@ -1144,21 +1266,16 @@ function useMarkdownStyles( title: node.title ?? null, }) ?? undefined) : undefined, - code_inline: ({ content }) => { - const value = content ?? ""; - return ( - - {value} - - ); - }, + code_inline: ({ content }) => ( + + ), ...(preserveSoftBreaks ? { soft_break: () => {"\n"}, @@ -1954,11 +2071,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { path: relativePath.split("/").filter((segment) => segment.length > 0), ...(presentation.line ? { line: String(presentation.line) } : {}), }); + return; + } + } + + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + if (media) { + void Haptics.selectionAsync(); + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); } return; } - if (presentation.href) { + if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, @@ -1972,14 +2104,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const renderMarkdownImage = useCallback( (image) => { + const media = resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }); + if (media?.kind === "video") { + return ( + setExpandedVideo((current) => current ?? source)} + /> + ); + } const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); if (imageSource._tag === "Direct") { return ( setExpandedFile((current) => current ?? source)} /> ); @@ -1991,12 +2139,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedFile((current) => current ?? source)} /> ); @@ -2009,12 +2158,26 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { threadId: props.threadId, workspaceRoot: props.workspaceRoot, }); + const media = viewedImage + ? resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }) + : null; + const actionsSource = media?.source.actionsSource; return viewedImage ? ( setExpandedFile((current) => current ?? source)} /> ) : null; @@ -2438,30 +2601,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { key={info.item.id} entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined} > - {renderFeedEntry(info, { - environmentId: props.environmentId, - copiedRowId, - expandedWorkRows, - terminalAssistantMessageIds, - unsettledTurnId, - onCopyWorkRow, - onToggleWorkGroup, - onToggleWorkRow, - onToggleTurnFold, - onPressPreview, - onPressVideo, - onMarkdownLinkPress, - renderMarkdownImage, - renderViewedImage, - iconSubtleColor, - userBubbleColor, - markdownStyles, - reviewCommentColors, - reviewCommentBubbleWidth, - userBubbleMaxWidth, - skills: props.skills, - onUseArtifactTemplate: props.onUseArtifactTemplate, - })} + + {renderFeedEntry(info, { + environmentId: props.environmentId, + copiedRowId, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, + onCopyWorkRow, + onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, + onPressPreview, + onPressVideo, + onMarkdownLinkPress, + renderMarkdownImage, + renderViewedImage, + iconSubtleColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + userBubbleMaxWidth, + skills: props.skills, + onUseArtifactTemplate: props.onUseArtifactTemplate, + })} + ), [ @@ -2586,6 +2751,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { data={presentedFeed} extraData={listAppearanceData} renderItem={renderItem} + viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 49a8b46648e1..bf3d009b74ab 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; describe("resolveMarkdownLinkPresentation", () => { + it("treats protocol-relative media as an external URL, not a filesystem path", () => { + expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({ + kind: "external", + href: "https://cdn.example.com/clip.mp4?sig=a%2fb#t=2", + host: "cdn.example.com", + }); + }); + + it("separates encoded filename characters from a video playback fragment", () => { + expect(resolveMarkdownLinkPresentation("/tmp/clip%23one.mp4#t=2")).toMatchObject({ + path: "/tmp/clip#one.mp4", + label: "clip#one.mp4", + icon: "video", + }); + }); + it("extracts external link hosts", () => { expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ kind: "external", @@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); - it("renders file URLs as basename pills with positions", () => { - expect( - resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), - ).toEqual({ + it.each([ + ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"], + ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"], + ])("preserves the file URL path and position for %s", (href, path) => { + expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "file", - href: "file:///Users/julius/project/src/main.ts#L42C7", + href, icon: "typescript", label: "main.ts:42:7", - path: "/Users/julius/project/src/main.ts", + path, line: 42, column: 7, }); diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts new file mode 100644 index 000000000000..77834630dc2f --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -0,0 +1,77 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownMediaPreview } from "./markdownMedia"; + +const input = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + workspaceRoot: "/repo", +}; + +describe("resolveMarkdownMediaPreview", () => { + it("decodes remote filenames once without changing the authored URL", () => { + const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2"; + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "video", + source: { + uri: href, + actionsSource: { + name: "clip one%20.mp4", + mimeType: "video/mp4", + reference: { kind: "url", url: href }, + }, + }, + }); + }); + + it("provides extensionless image actions only for image embeds", () => { + const href = "https://cdn.example.com/render?id=42"; + expect(resolveMarkdownMediaPreview(href, input)).toBeNull(); + expect(resolveMarkdownMediaPreview(href, { ...input, imageEmbed: true })).toMatchObject({ + kind: "image", + source: { actionsSource: { reference: { kind: "url", url: href }, mimeType: "image/*" } }, + }); + }); + + it.each([ + ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], + ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], + ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], + ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], + ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], + ])("keeps encoded filename and UNC semantics for %s", (href, path) => { + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "image", + source: { + resource: { path }, + actionsSource: { reference: { kind: "file", path } }, + }, + }); + }); + + it("separates a video playback fragment from literal filename characters", () => { + expect(resolveMarkdownMediaPreview("/tmp/clip%23one.mp4#t=2", input)).toMatchObject({ + kind: "video", + source: { + srcFragment: "#t=2", + resource: { path: "/tmp/clip#one.mp4" }, + actionsSource: { reference: { kind: "file", path: "/tmp/clip#one.mp4" } }, + }, + }); + }); + + it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { + expect( + resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), + ).toMatchObject({ + kind: "video", + source: { + uri: "https://cdn.example.com/clip.mp4?signature=a%2fb#t=2", + actionsSource: { + reference: { kind: "url", url: "//cdn.example.com/clip.mp4?signature=a%2fb#t=2" }, + }, + }, + }); + }); +}); diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts new file mode 100644 index 000000000000..2196c2f22f2b --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -0,0 +1,89 @@ +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { + mediaFileReference, + mediaReferenceFileName, + mediaUrlReference, +} from "@t3tools/client-runtime/media-reference"; + +import type { FilePreviewSource } from "../components/FilePreviewModal"; +import type { MediaVideoPreviewSource } from "./videoPreviewSource"; +import type { MediaActionsSource } from "./mediaActions"; + +/** Resolves only explicit media references. Ordinary links keep their existing navigation. */ +export function resolveMarkdownMediaPreview( + href: string, + input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null | undefined; + /** Image syntax can target an endpoint without a recognizable extension. */ + readonly imageEmbed?: boolean; + }, +): + | { readonly kind: "image"; readonly source: FilePreviewSource } + | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } + | null { + const classified = classifyMarkdownImageSource(href, input.workspaceRoot); + if (classified._tag === "Blocked") return null; + const path = + classified._tag === "WorkspaceFile" + ? classified.path.replace(/:\d+(?::\d+)?$/, "") + : classified.uri.split(/[?#]/, 1)[0]!; + const basename = path.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = basename.lastIndexOf("."); + // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. + const detectedMimeType = + classified._tag === "Direct" + ? mediaMimeType(classified.uri) + : extensionIndex < 0 + ? null + : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); + const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); + if (mimeType === null) return null; + const kind = mimeType.startsWith("video/") ? "video" : "image"; + const reference = + classified._tag === "Direct" + ? mediaUrlReference(classified.uri) + : mediaFileReference(path, input.workspaceRoot); + const name = + (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); + const srcFragment = markdownImageSourceFragment(href); + const target = + classified._tag === "Direct" + ? { uri: normalizeNativeMarkdownUrl(classified.uri) } + : { + environmentId: input.environmentId, + resource: { + _tag: "media-file" as const, + threadId: input.threadId, + path, + }, + ...(srcFragment ? { srcFragment } : {}), + }; + const actionsSource: MediaActionsSource = + classified._tag === "Direct" + ? { reference, uri: classified.uri, name, mimeType } + : { + reference, + environmentId: input.environmentId, + threadId: input.threadId, + resource: { _tag: "media-file", threadId: input.threadId, path }, + name, + mimeType, + }; + return kind === "video" + ? { + kind, + source: { type: "media", name, mimeType, ...target, actionsSource }, + } + : { + kind, + source: { kind, name, ...target, actionsSource }, + }; +} diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts new file mode 100644 index 000000000000..14dc2de7bd21 --- /dev/null +++ b/apps/mobile/src/lib/mediaActions.ts @@ -0,0 +1,121 @@ +import { useNavigation } from "@react-navigation/native"; +import type { MediaReference } from "@t3tools/client-runtime/media-reference"; +import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "react-native"; + +import { useRefreshAssetUrl } from "../state/assets"; +import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import { copyTextWithHaptic } from "./copyTextWithHaptic"; + +/** Authored source metadata is kept separate from temporary preview/download URLs. */ +export type MediaActionsSource = { + readonly reference?: MediaReference; + readonly name: string; + readonly mimeType: string; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly resource: AssetResource; + } +); + +export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { + const navigation = useNavigation(); + const refresh = useRefreshAssetUrl( + source && "environmentId" in source ? source.environmentId : null, + source && "resource" in source ? source.resource : null, + ); + const controller = useRef(null); + const [sharing, setSharing] = useState(false); + useEffect(() => () => controller.current?.abort(), []); + + const share = () => { + if (!source || controller.current) return; + const request = new AbortController(); + controller.current = request; + setSharing(true); + void (async () => { + const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); + if (request.signal.aborted) return; + if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); + const input = { + attachment: { name: source.name, mimeType: source.mimeType }, + signal: request.signal, + }; + if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); + else await downloadAndShareAttachment({ ...input, url: uri }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (controller.current === request) { + controller.current = null; + if (!request.signal.aborted) setSharing(false); + } + }); + }; + + const reference = source?.reference; + const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source + ? [ + ...(reference?.kind === "file" + ? [ + { + id: "copy-path", + title: "Copy full path", + run: () => copyTextWithHaptic(reference.path), + }, + ...(reference.relativePath + ? [ + { + id: "copy-relative-path", + title: "Copy relative path", + run: () => copyTextWithHaptic(reference.relativePath!), + }, + ] + : []), + ...(reference.relativePath && source && "environmentId" in source + ? [ + { + id: "open-file", + title: "Open in file viewer", + run: () => { + onOpenFile?.(); + navigation.navigate("ThreadFile", { + environmentId: String(source.environmentId), + threadId: String(source.threadId), + path: reference.relativePath!.split("/"), + }); + }, + }, + ] + : []), + ] + : reference + ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] + : []), + { + id: "share", + title: sharing ? "Opening share sheet…" : "Save or share", + run: share, + disabled: sharing, + }, + ] + : []; + return { + title: reference?.kind === "file" ? reference.path : reference?.url, + actions, + sharing, + share, + }; +} diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 867d9e983017..1e7cb5f3164e 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -11,6 +11,43 @@ import { } from "@t3tools/mobile-markdown-text/markdown"; describe("nativeMarkdownTextRuns", () => { + it("links a path-shaped code span without changing the same path in prose", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { type: "text", content: "/tmp/frame.png " }, + { type: "code_inline", content: "/tmp/frame.png" }, + ], + }), + ).toEqual([ + { text: "/tmp/frame.png " }, + { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" }, + ]); + }); + + it("preserves the destination of a link with a code-formatted label", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "code_inline", content: "src/main.ts" }], + }, + ], + }), + ).toEqual([ + { + text: "src/main.ts", + code: true, + href: "https://example.com/docs", + externalHost: "example.com", + }, + ]); + }); + it("preserves inline emphasis and code styles", () => { const node: MarkdownNode = { type: "paragraph", diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts new file mode 100644 index 000000000000..af87a8d0f73a --- /dev/null +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -0,0 +1,54 @@ +import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import type { MediaActionsSource } from "./mediaActions"; + +export type MediaVideoPreviewSource = { + readonly type: "media"; + readonly name: string; + readonly mimeType: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly resource: Extract; + } +); + +/** Resolves the current capability without making it the identity of the video. */ +export function mediaVideoPreviewUri( + source: MediaVideoPreviewSource, + assetUrl: string | null, +): string | null { + if ("uri" in source) return source.uri; + return assetUrl === null ? null : assetUrl + (source.srcFragment ?? ""); +} + +/** Keeps thumbnails independent of refreshed asset signatures and scoped to their environment. */ +export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string { + return JSON.stringify( + "uri" in source + ? ["media-video", source.uri] + : [ + "media-video", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ], + ); +} + +export type AttachmentVideoPreviewSource = ( + | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } + | { + readonly type: "remote"; + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + } +) & { readonly sourceIdentifier?: string }; + +export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 611a1ed8b99b..9e3e43c7cdcb 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react"; import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; +import { useAtomQueryRunner } from "./use-atom-query-runner"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); @@ -44,3 +46,23 @@ export function useAssetUrl( const state = useAssetUrlState(environmentId, resource); return state._tag === "Success" ? state.url : null; } + +/** Explicit playback and sharing must reauthorize files that may have been replaced on disk. */ +export function useRefreshAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): () => Promise { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + return useCallback(async () => { + if (environmentId === null || resource === null || httpBaseUrl === null) return null; + const result = await createUrl({ environmentId, input: { resource } }); + return result._tag === "Success" + ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) + : null; + }, [createUrl, environmentId, httpBaseUrl, resource]); +} diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts index 22f971e09a5d..691b1f43cb87 100644 --- a/apps/mobile/src/state/use-atom-query-runner.ts +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -1,7 +1,7 @@ import { RegistryContext } from "@effect/atom-react"; import { executeAtomQuery, - type AtomCommandOptions, + type AtomQueryOptions, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, - options?: string | AtomCommandOptions, + options?: string | AtomQueryOptions, ): (target: T) => Promise> { const registry = useContext(RegistryContext); const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + const refresh = typeof options === "string" ? false : (options?.refresh ?? false); return useCallback( (target: T) => { @@ -23,8 +24,9 @@ export function useAtomQueryRunner( label: explicitLabel ?? atom.label?.[0] ?? "atom query", reportFailure, reportDefect, + refresh, }); }, - [explicitLabel, family, registry, reportDefect, reportFailure], + [explicitLabel, family, registry, refresh, reportDefect, reportFailure], ); } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8cf9c642384c..4a47a17fabc3 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,4 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeFSP from "node:fs/promises"; import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; @@ -9,18 +12,28 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; +import { HttpServerResponse } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { openMediaFile } from "./MediaFile.ts"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; +}); const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); const testLayer = Layer.mergeAll( + NodeHttpPlatform.layer, configLayer, WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe( @@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("issues exact URLs for images and videos outside the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-root-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-outside-" }); + for (const [name, mimeType] of [ + ["screenshot.png", "image/png"], + ["recording.mp4", "video/mp4"], + ["recording.webm", "video/webm"], + ] as const) { + const filePath = path.join(outside, name); + yield* fs.writeFileString(filePath, "media"); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + expect(yield* resolveAsset(token, suffix.slice(separator + 1))).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType, + }); + yield* fs.writeFileString(path.join(outside, "sibling.png"), "private sibling"); + expect(yield* resolveAsset(token, "sibling.png")).toBeNull(); + expect(yield* resolveAsset(token, `../${name}`)).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("resolves relative media paths from the thread workspace, including outside it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-relative-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + for (const relativePath of ["screenshot.png", "../recording.mp4"]) { + const filePath = path.resolve(root, relativePath); + yield* fs.writeFileString(filePath, "media"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: relativePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ + kind: "file", + path: yield* fs.realPath(filePath), + }); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects non-media files, disguised targets, and directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); + for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + const filePath = path.join(root, name); + yield* fs.writeFileString(filePath, "not media"); + const error = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + } + const disguisedPath = path.join(root, "disguised.png"); + yield* fs.symlink(path.join(root, "report.html"), disguisedPath); + const disguisedError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, + }).pipe(Effect.flip); + expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError); + const directoryPath = path.join(root, "directory.png"); + yield* fs.makeDirectory(directoryPath); + const directoryError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath }, + }).pipe(Effect.flip); + expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("binds media URLs to the canonical target and rejects symlink substitution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" }); + const filePath = path.join(root, "actual.svg"); + const aliasPath = path.join(root, "alias.png"); + const replacementPath = path.join(root, "other.svg"); + yield* fs.writeFileString(filePath, ""); + yield* fs.writeFileString(replacementPath, "private"); + yield* fs.symlink(filePath, aliasPath); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" }; + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(aliasPath); + yield* fs.symlink(replacementPath, aliasPath); + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(filePath); + yield* fs.symlink(replacementPath, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps full and partial responses bound to the file opened during resolution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" }); + const filePath = path.join(root, "recording.mp4"); + const savedPath = path.join(root, "saved.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "0123456789"); + yield* fs.writeFileString(secretPath, "private information"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + for (const [range, expected, status] of [ + [undefined, "0123456789", 200], + ["bytes=2-5", "2345", 206], + ] as const) { + const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + if (!asset) throw new Error("Expected a resolved media file"); + + yield* fs.rename(filePath, savedPath); + yield* fs.symlink(secretPath, filePath); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range)); + expect(response.status).toBe(status); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + yield* fs.remove(filePath); + yield* fs.rename(savedPath, filePath); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a symlink swapped in after canonical validation but before open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + stat: Effect.fn(function* (requestedPath) { + const info = yield* fs.stat(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.remove(filePath); + yield* fs.symlink(secretPath, filePath); + } + return info; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("closes a descriptor rejected when its path changes during open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const originalOpen = (yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + )).open; + let opened: NodeFSP.FileHandle | undefined; + const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (target === canonicalPath) { + opened = handle; + await NodeFSP.unlink(filePath); + await NodeFSP.symlink(secretPath, filePath); + } + return handle; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen))); + expect(yield* openMediaFile(canonicalPath)).toBeNull(); + expect(opened).toBeDefined(); + expect(opened?.fd).toBe(-1); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" }); + const publicDirectory = path.join(root, "public"); + const privateDirectory = path.join(root, "private"); + yield* fs.makeDirectory(publicDirectory); + yield* fs.makeDirectory(privateDirectory); + const filePath = path.join(publicDirectory, "recording.mp4"); + yield* fs.writeFileString(filePath, "public video"); + yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const native = yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + ); + const savedDirectory = path.join(root, "saved"); + const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => { + // A pathname-only guard can see the original parents during realpath, + // but the private file during both lstat calls and open. + await native.unlink(publicDirectory); + await native.rename(savedDirectory, publicDirectory); + const canonical = await native.realpath(canonicalPath); + await native.rename(publicDirectory, savedDirectory); + await native.symlink(privateDirectory, publicDirectory, "junction"); + return canonical; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)), + ); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + realPath: Effect.fn(function* (requestedPath) { + const canonical = yield* fs.realPath(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.rename(publicDirectory, savedDirectory); + yield* Effect.promise(() => + NodeFSP.symlink(privateDirectory, publicDirectory, "junction"), + ); + } + return canonical; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-replacement-" }); + const filePath = path.join(root, "recording.mp4"); + yield* fs.writeFileString(filePath, "original"); + const input = { + resource: { + _tag: "media-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + }; + const original = yield* issueAssetUrl(input); + const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + yield* fs.writeFileString(filePath, "in-place edit"); + const edited = yield* resolveAsset(token, name); + if (!edited) throw new Error("Expected the edited media file"); + const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); + expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); + + const replacement = path.join(root, "replacement.mp4"); + yield* fs.writeFileString(replacement, "replacement"); + yield* fs.rename(replacement, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + + const renewed = yield* issueAssetUrl(input); + const renewedSuffix = renewed.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const renewedSeparator = renewedSuffix.indexOf("/"); + const renewedAsset = yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ); + if (!renewedAsset) throw new Error("Expected the replacement media file"); + const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); + expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); + yield* fs.remove(filePath); + expect( + yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index d064ec07529a..05801acca88f 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -16,6 +16,7 @@ import { import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + mediaMimeTypeFromExtension, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -41,6 +42,7 @@ import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../atta import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -76,6 +78,14 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("media-file-exact"), + filePath: Schema.String, + device: Schema.String, + inode: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), @@ -115,6 +125,7 @@ export type ResolvedAsset = { readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; + readonly file?: OpenMediaFile; }; function decodeClaims(encodedPayload: string): AssetClaims | null { @@ -211,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let sourcePath: string | undefined; switch (input.resource._tag) { + case "media-file": { + let requestedPath = input.resource.path; + if (!path.isAbsolute(requestedPath)) { + if (!input.workspaceRoot) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }), + ), + ); + requestedPath = path.resolve(workspaceRoot, requestedPath); + } + const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe( + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!canonicalFile) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + const identity = yield* openMediaFile(canonicalFile).pipe( + Effect.map((file) => + file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null, + ), + Effect.scoped, + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!identity) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + claims = { + version: 1, + kind: "media-file-exact", + filePath: canonicalFile, + ...identity, + expiresAt, + }; + fileName = path.basename(canonicalFile); + break; + } case "workspace-file": { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ @@ -526,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; + if (claims.kind === "media-file-exact") { + if (decodedPath !== path.basename(claims.filePath)) return null; + const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical media path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (canonicalFile !== claims.filePath) return null; + const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile)); + if (!mimeType) return null; + const file = yield* openMediaFile(canonicalFile, claims).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }), + ), + Effect.orElseSucceed(() => null), + ); + return file + ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) + : null; + } if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts new file mode 100644 index 000000000000..f1b63bb659e3 --- /dev/null +++ b/apps/server/src/assets/MediaFile.ts @@ -0,0 +1,113 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem does not expose no-follow +// or non-blocking open flags, and the response must keep the validated descriptor. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + +import * as NodeStream from "@effect/platform-node/NodeStream"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +class MediaFileOpenError extends Schema.TaggedErrorClass()( + "MediaFileOpenError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open media file '${this.path}'.`; + } +} + +class MediaFileStatError extends Schema.TaggedErrorClass()( + "MediaFileStatError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read metadata for media file '${this.path}'.`; + } +} + +/** Holds the file identity and descriptor for one HTTP request, never a copy of its bytes. */ +export interface OpenMediaFile { + readonly handle: NodeFSP.FileHandle; + readonly info: NodeFS.BigIntStats; +} + +/** Opens a canonical media path once. Replacements cannot change the response's source. */ +export const openMediaFile = Effect.fn("openMediaFile")(function* ( + filePath: string, + identity?: { readonly device: string; readonly inode: string }, +) { + return yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const before = await NodeFSP.lstat(filePath, { bigint: true }); + if (!before.isFile() || before.ino === 0n) return null; + if ( + identity && + (before.dev.toString() !== identity.device || before.ino.toString() !== identity.inode) + ) { + return null; + } + + // Windows lacks these flags; the descriptor/path identity checks still apply. + const handle = await NodeFSP.open( + filePath, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), + ); + let accepted = false; + try { + const info = await handle.stat({ bigint: true }); + if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino) return null; + if ( + identity && + (info.dev.toString() !== identity.device || info.ino.toString() !== identity.inode) + ) { + return null; + } + if ((await NodeFSP.realpath(filePath)) !== filePath) return null; + const after = await NodeFSP.lstat(filePath, { bigint: true }); + if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null; + accepted = true; + return { handle, info } satisfies OpenMediaFile; + } finally { + if (!accepted) await handle.close(); + } + }, + catch: (cause) => new MediaFileOpenError({ path: filePath, cause }), + }), + (file) => (file ? Effect.promise(() => file.handle.close()) : Effect.void), + ); +}); + +export const statMediaFile = Effect.fn("statMediaFile")(function* ( + filePath: string, + file: OpenMediaFile, +) { + return yield* Effect.tryPromise({ + try: () => file.handle.stat({ bigint: true }), + catch: (cause) => new MediaFileStatError({ path: filePath, cause }), + }); +}); + +export const streamMediaFile = (file: OpenMediaFile, offset: bigint, bytesToRead: bigint) => { + const start = Number(offset); + const end = Number(offset + bytesToRead - 1n); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { + return null; + } + return NodeStream.fromReadable({ + evaluate: () => + file.handle.createReadStream({ + autoClose: false, + start, + end, + }), + }); +}; diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 7ae036bdc99e..9d54adef8eda 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest"; -import { describe } from "vite-plus/test"; +import { describe, vi } from "vite-plus/test"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -7,6 +7,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { HttpServerResponse } from "effect/unstable/http"; +import { openMediaFile } from "./assets/MediaFile.ts"; import { assetResponseHeaders, @@ -19,6 +20,179 @@ import { const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); describe("video asset byte ranges", () => { + it.effect("uses current descriptor metadata after an in-place truncate or extension", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" }); + const filePath = path.join(directory, "clip.mp4"); + for (const [contents, range, method, expected, status, contentRange] of [ + ["1234", undefined, "GET", "1234", 200, null], + ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null], + ["1234", "bytes=4-", "GET", "", 416, "bytes */4"], + ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"], + ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"], + ["0123456789abcdef", undefined, "HEAD", "", 200, null], + ["", undefined, "GET", "", 200, null], + ["", "bytes=0-1", "GET", "", 416, "bytes */0"], + ] as const) { + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + yield* fs.writeFileString(filePath, contents); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + undefined, + method, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("content-range")).toBe(contentRange); + if (status !== 416) { + expect(response.headers.get("content-length")).toBe( + String(method === "HEAD" ? contents.length : expected.length), + ); + } + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect( + "rejects unaddressable ranges before streaming and preserves small ranges on large files", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const size = unsafeOffset + 32n; + for (const [range, status] of [ + [`bytes=${unsafeOffset}-${unsafeOffset}`, 416], + [`bytes=0-${unsafeOffset}`, 416], + ["bytes=-1", 416], + [undefined, 413], + ["bytes=0-1", 206], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + // Model a sparse file beyond the native stream's numeric addressing limit. + const info = yield* Effect.promise(() => file.handle.stat({ bigint: true })); + info.size = size; + const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info); + yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore())); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range), + ); + expect(response.status).toBe(status); + if (status === 416) { + expect(response.headers.get("content-range")).toBe(`bytes */${size}`); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (status === 206) { + expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`); + expect(yield* Effect.promise(() => response.text())).toBe("01"); + } else { + expect(yield* Effect.promise(() => response.text())).toBe( + "File is too large to preview.", + ); + } + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("streams guarded file ranges, including suffixes and conditional requests", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + for (const [range, ifRange, expected, status, contentRange] of [ + [undefined, undefined, "0123456789", 200, null], + ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"], + ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"], + ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"], + ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"], + ["bytes=10-", undefined, "", 416, "bytes */10"], + ["bytes=0-1", '"old-etag"', "0123456789", 200, null], + ["bytes=0-1", "", "0123456789", 200, null], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + ifRange, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe(contentRange); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("etag")).toBeNull(); + expect(response.headers.get("last-modified")).toBeNull(); + if (status !== 416) + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" }); + const filePath = path.join(directory, "clip.mp4"); + const bytes = new Uint8Array(1024 * 1024).fill(42); + yield* fs.writeFile(filePath, bytes); + const canonicalPath = yield* fs.realPath(filePath); + for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) { + const file = yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-", + undefined, + mode === "HEAD" ? "HEAD" : "GET", + ), + ); + if (mode === "HEAD") { + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe(String(bytes.length)); + expect(response.headers.get("content-range")).toBeNull(); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "rejected") { + expect(response.status).toBe(416); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "cancelled") { + const reader = response.body!.getReader(); + const first = yield* Effect.promise(() => reader.read()); + expect(first.done).toBe(false); + expect(first.value!.byteLength).toBeLessThan(bytes.length); + yield* Effect.promise(() => reader.cancel()); + } else { + expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer); + } + return file; + }), + ); + expect(file.handle.fd).toBe(-1); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + it.effect("streams exactly the requested bytes and leaves full downloads intact", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index b83461775e91..8b7c2ad3a61b 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -29,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, storeAttachmentUpload, @@ -124,6 +125,9 @@ function assetByteRange(header: string, size: bigint) { } const start = first ?? (last! >= size ? 0n : size - last!); const end = first === null || last === null || last >= size ? size - 1n : last; + if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) { + return { _tag: "Unsatisfiable" as const }; + } return { _tag: "Range" as const, offset: start, @@ -138,17 +142,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; + readonly file?: OpenMediaFile; }, rangeHeader?: string, ifRangeHeader?: string, + method: "GET" | "HEAD" = "GET", ) { const headers = assetResponseHeaders(asset.path, asset); - if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { + const mediaFile = asset.file; + const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; + const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true; + if (mediaFile && isVideo) { + // Host videos can change in place. Do not invite conditional range requests + // with validators that cannot establish byte-for-byte identity. + headers["Cache-Control"] = "private, no-store"; + } + let status = 200; + let offset = 0n; + let bytesToRead: bigint | undefined; + if (isVideo) { headers["Accept-Ranges"] = "bytes"; // If-Range requires a matching validator. A full response is safe when we cannot validate it. - if (rangeHeader && !ifRangeHeader) { + if (method === "GET" && rangeHeader && ifRangeHeader === undefined) { const fs = yield* FileSystem.FileSystem; - const info = yield* fs.stat(asset.path); + const info = mediaInfo ?? (yield* fs.stat(asset.path)); const range = assetByteRange(rangeHeader, info.size); if (range?._tag === "Unsatisfiable") { return HttpServerResponse.empty({ @@ -157,16 +174,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( }); } if (range?._tag === "Range") { - return yield* HttpServerResponse.file(asset.path, { - status: 206, - offset: range.offset, - bytesToRead: range.bytesToRead, - headers: { ...headers, "Content-Range": range.contentRange }, - }); + status = 206; + offset = range.offset; + bytesToRead = range.bytesToRead; + headers["Content-Range"] = range.contentRange; } } } - return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); + if (mediaFile && mediaInfo) { + const size = bytesToRead ?? mediaInfo.size; + headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream"; + headers["Content-Length"] = String(size); + if (!isVideo) { + headers["Last-Modified"] = mediaInfo.mtime.toUTCString(); + headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`; + } + if (method === "HEAD" || size === 0n) { + return HttpServerResponse.empty({ status, headers }); + } + const body = streamMediaFile(mediaFile, offset, size); + if (!body) { + return HttpServerResponse.text("File is too large to preview.", { status: 413 }); + } + return HttpServerResponse.stream(body, { + status, + headers, + }); + } + return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers }); }); export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { @@ -338,6 +373,7 @@ export const assetRouteLayer = HttpRouter.add( asset, request.method === "GET" ? request.headers.range : undefined, request.headers["if-range"], + request.method === "HEAD" ? "HEAD" : "GET", ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index f8c0b5ae75f7..5c642471404a 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,11 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; +import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; @@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc return result.url; } +/** Re-mints an exact-file capability after a file change or an explicit retry. */ +export function useAssetUrlRefresh( + environmentId: EnvironmentId, + resource: AssetResource, +): () => Promise { + const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + return useCallback(async () => { + const result = await refresh({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }, [environmentId, resource, refresh]); +} + export function useAssetUrls( environmentId: EnvironmentId, resources: ReadonlyArray, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 470f0efcc26c..6f7327b3ab3e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -42,6 +42,9 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; +import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { @@ -76,7 +79,14 @@ import { renderCodexFileCitationsAsMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; -import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { + resolveMarkdownMediaPreview, + type ExpandedImagePreview, +} from "./chat/ExpandedImagePreview"; +import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { @@ -126,7 +136,7 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; @@ -195,8 +205,14 @@ export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; + canOpenMedia?: boolean; }): boolean { - return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; + return ( + input.canOpenInEditor || + input.canOpenInBrowser || + input.canOpenInPanel || + input.canOpenMedia === true + ); } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { @@ -1011,6 +1027,7 @@ interface MarkdownFileLinkProps { onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onOpenMedia?: (() => void) | undefined; onReveal?: (() => Promise>) | undefined; /** Platform-specific menu label ("Reveal in Finder", ...); required for the reveal item to show. */ @@ -1133,10 +1150,16 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); -const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( + "max-h-[30rem]", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, +); +const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; +const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( "h-auto w-auto object-contain", - CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, ); function markdownImageCopy(alt: string, src: string, title: string | undefined): string { @@ -1167,11 +1190,10 @@ function authoredImageSizeStyle( return undefined; } -const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, - CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, - "rounded-lg border border-border/40", + CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); const MarkdownLinkContext = React.createContext(false); @@ -1179,6 +1201,8 @@ function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, src: string, alt: string, + originalUrl?: string, + actionsSource?: MediaActionSource, ) { if (!onImageExpand) return {}; const previewName = alt.trim() || "image"; @@ -1186,7 +1210,17 @@ function expandableMarkdownImageProps( if (event.currentTarget.closest("a")) return; event.preventDefault(); event.stopPropagation(); - onImageExpand({ images: [{ src, name: previewName }], index: 0 }); + onImageExpand({ + images: [ + { + src, + name: previewName, + ...(originalUrl ? { originalUrl } : {}), + ...(actionsSource ? { actionsSource } : {}), + }, + ], + index: 0, + }); }; return { role: "button" as const, @@ -1202,70 +1236,201 @@ function expandableMarkdownImageProps( function ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; + readonly kind?: "image" | "video"; + readonly actionsSource?: MediaActionSource; }) { - return ( + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + const content = ( - {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + {props.alt.length > 0 ? `${label} · ${props.alt}` : label} ); + return props.actionsSource ? ( + {content} + ) : ( + content + ); +} + +function ChatMarkdownVideo(props: { + readonly src: string | null; + readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly originalUrl?: string | undefined; + readonly sourceFailed?: boolean | undefined; + readonly style?: CSSProperties | undefined; + readonly mediaIdentity?: string | undefined; + readonly actionsSource?: MediaActionSource | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + return ( + { + props.onImageExpand?.({ + images: [ + { + src, + name: props.alt || "video", + type: "video", + autoPlay: false, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + ...(props.actionsSource + ? { actionsSource: { ...props.actionsSource, src } } + : {}), + }, + ], + index: 0, + }); + } + : undefined + } + /> + ); } -/** Environment-hosted images load through a signed asset URL. */ +/** Environment-hosted media loads through an exact-file signed asset URL. */ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract< + AssetResource, + { readonly _tag: "attachment" | "workspace-file" | "media-file" } + >; + readonly kind?: "image" | "video"; readonly alt: string; readonly copyMarkdown?: string; readonly srcFragment?: string; readonly style?: CSSProperties | undefined; + readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); const [failedUrl, setFailedUrl] = useState(null); + const resource = props.resource; + const path = + resource._tag === "media-file" + ? resource.path + : resource._tag === "workspace-file" && props.workspaceRoot + ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` + : undefined; + const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; + const relativePath = reference?.relativePath; + const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const actionsSource: MediaActionSource = { + kind: props.kind ?? "image", + name: props.alt || (props.kind ?? "image"), + src, + asset: { environmentId: props.environmentId, resource }, + ...(reference ? { reference } : {}), + ...(relativePath && resource._tag !== "attachment" + ? { + onOpenFile: () => + useRightPanelStore + .getState() + .openFile( + { environmentId: props.environmentId, threadId: resource.threadId }, + relativePath, + ), + } + : {}), + }; + + if (props.kind === "video") { + return ( + + ); + } if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ; + return ( + + ); } if (assetUrl._tag !== "Success") { return ( - + + + ); + } + return ( + + {props.alt} setFailedUrl(assetUrl.url)} /> - ); - } - const src = assetUrl.url + (props.srcFragment ?? ""); - return ( - {props.alt} setFailedUrl(assetUrl.url)} - /> + ); }); @@ -1448,6 +1613,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onOpenInPanel, openInEditorMenuLabel, onOpenInBrowser, + onOpenMedia, onReveal, revealLabel, className, @@ -1491,12 +1657,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { - if (!threadRef || !workspaceRelativePath) { - handleOpenInEditor(); + if (threadRef && workspaceRelativePath) { + onOpenInPanel(workspaceRelativePath, line); return; } - onOpenInPanel(workspaceRelativePath, line); - }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); + if (onOpenMedia) { + onOpenMedia(); + return; + } + handleOpenInEditor(); + }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1621,6 +1791,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ try { const clicked = await api.contextMenu.show( [ + ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) @@ -1632,6 +1803,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ position, ); + if (clicked === "preview-media") { + onOpenMedia?.(); + return; + } if (clicked === "open") { handleOpenInEditor(); return; @@ -1665,6 +1840,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor, handleRevealInFileManager, onOpenInBrowser, + onOpenMedia, onOpen, onReveal, openInEditorMenuLabel, @@ -1696,6 +1872,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ canOpenInEditor, canOpenInBrowser, canOpenInPanel, + canOpenMedia: onOpenMedia !== undefined, }); const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ iconPath, @@ -1787,6 +1964,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenInPanel === next.onOpenInPanel && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && + previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && previous.revealLabel === next.revealLabel && previous.className === next.className @@ -1810,8 +1988,18 @@ function ChatMarkdown({ extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const [localMediaPreview, setLocalMediaPreview] = useState(null); + const expandMedia = onImageExpand ?? setLocalMediaPreview; + const mediaRequestId = useRef(0); + useEffect(() => { + setLocalMediaPreview(null); + return () => { + mediaRequestId.current += 1; + }; + }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, + refresh: true, }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, @@ -1830,6 +2018,41 @@ function ChatMarkdown({ remoteOpen.isResolved, ); const preparedConnection = usePreparedConnection(environmentId); + const openMarkdownMedia = useCallback( + (source: string, resolvedFilePath?: string) => { + const requestId = ++mediaRequestId.current; + void resolveMarkdownMediaPreview({ + source, + resolvedFilePath, + cwd, + threadRef, + httpBaseUrl: + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined, + createAssetUrl, + onOpenFile: threadRef + ? (path) => useRightPanelStore.getState().openFile(threadRef, path) + : undefined, + }).then( + (preview) => { + if (preview && mediaRequestId.current === requestId) expandMedia(preview); + }, + (error: unknown) => { + if (mediaRequestId.current !== requestId) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Media unavailable", + description: + error instanceof Error + ? error.message + : "The file could not be loaded. It may have been moved or deleted.", + }), + ); + }, + ); + }, + [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], + ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const threadServerConfig = useAtomValue( serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), @@ -2068,6 +2291,7 @@ function ChatMarkdown({ fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, + mediaSource?: string, ) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), @@ -2081,6 +2305,11 @@ function ChatMarkdown({ `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, ); } + const mediaPath = mediaSource ?? fileLinkMeta.filePath; + const canPreviewMedia = + mediaMimeTypeFromExtension( + fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), + ) !== null; return ( openMarkdownMedia(mediaPath, fileLinkMeta.filePath) + : undefined + } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined @@ -2232,6 +2466,21 @@ function ChatMarkdown({ handleMarkdownFragmentClick(event, href); return; } + if ( + href && + faviconHost !== null && + mediaKindFromPath(href) !== null && + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + event.stopPropagation(); + openMarkdownMedia(href); + return; + } // A link to a change request in a workspace project opens beside the // conversation instead of in a browser: it is the thing being talked about, and // the panel it opens offers the browser as one of its actions. Anything else is @@ -2323,6 +2572,7 @@ function ChatMarkdown({ fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, props.className, + normalizedHref, ); }, code({ node, children, className, ...props }) { @@ -2332,7 +2582,12 @@ function ChatMarkdown({ inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? resolveInlineCodeFileLinkMeta(codeText, cwd); if (fileLinkMeta) { - return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + return fileLinkChip( + fileLinkMeta, + `\`${codeText}\``, + undefined, + inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), + ); } } return ( @@ -2342,7 +2597,7 @@ function ChatMarkdown({ ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { - const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand; + const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; const authoredSrc = typeof localSrc === "string" ? localSrc : src; @@ -2355,21 +2610,53 @@ function ChatMarkdown({ const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); + const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { + const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); + const originalUrl = + resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined; + const reference = mediaUrlReference(imageSource.uri); + const actionsSource: MediaActionSource = { + kind, + name: altText || kind, + src: mediaSrc, + ...(reference ? { reference } : {}), + }; + if (kind === "video") { + return ( + + ); + } return ( - {altText} + + {altText} + ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { @@ -2377,19 +2664,21 @@ function ChatMarkdown({ ); } - return ; + return ; }, table({ node: _node, ...props }) { return ; @@ -2438,6 +2727,8 @@ function ChatMarkdown({ onTaskListChange, onUseArtifactTemplate, onImageExpand, + expandMedia, + openMarkdownMedia, openFileInPanel, openInPreferredEditor, openChangeRequestLink, @@ -2483,6 +2774,12 @@ function ChatMarkdown({ > {text} + {localMediaPreview ? ( + setLocalMediaPreview(null)} + /> + ) : null} ); } diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39ea214d43c3..172793bacb0c 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../assets/assetUrls", () => ({ + useAssetUrlRefresh: () => vi.fn(), useAssetUrlState: (_environmentId: unknown, resource: unknown) => { testState.resources.push(resource); if (testState.assetState === "loading") return { _tag: "Loading" }; @@ -106,7 +107,7 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: expectedPath, }, @@ -126,14 +127,14 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "\\\\server\\share\\workspace-image.svg", }, @@ -149,7 +150,7 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "D:/screens/workspace-image.svg", }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 6e391ab79e95..64ad1ce7d782 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -22,7 +22,6 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, - loadVideoPreviewUrl, isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, @@ -44,23 +43,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; -describe("loadVideoPreviewUrl", () => { - it("loads video bytes into an object URL", async () => { - const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA=="); - expect(objectUrl).toMatch(/^blob:/); - URL.revokeObjectURL(objectUrl); - }); - - it("stops loading when the preview request is cancelled", async () => { - const controller = new AbortController(); - controller.abort(); - - await expect( - loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal), - ).rejects.toMatchObject({ name: "AbortError" }); - }); -}); - describe("isVideoPreviewRequestCurrent", () => { it("rejects changed threads and replaced previews", () => { expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a12bacad50dd..de349e3dbee7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,7 @@ import { + type AssetCreateUrlInput, + type AssetCreateUrlResult, + type ChatFileAttachment, type EnvironmentId, isProviderDriverKind, ProjectId, @@ -12,6 +15,12 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { videoMimeType } from "@t3tools/shared/video"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, @@ -299,10 +308,32 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { URL.revokeObjectURL(previewUrl); } -export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise { - const response = await fetch(url, signal ? { signal } : {}); - if (!response.ok) throw new Error(`Could not load video (${response.status}).`); - return URL.createObjectURL(await response.blob()); +/** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */ +export async function resolveFileAttachmentUrl(input: { + attachment: ChatFileAttachment; + environmentId: EnvironmentId; + httpBaseUrl: string; + createAssetUrl: (input: { + environmentId: EnvironmentId; + input: AssetCreateUrlInput; + }) => Promise>; +}): Promise { + const { attachment } = input; + const result = await input.createAssetUrl({ + environmentId: input.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (url === null) throw new Error("The environment returned an invalid attachment URL."); + return url; } export function isVideoPreviewRequestCurrent( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a475230fe829..dc0d2ed122f5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -365,7 +365,7 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - loadVideoPreviewUrl, + resolveFileAttachmentUrl, isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, @@ -424,7 +424,7 @@ import { resolveServerSelfUpdateCapability, serverUpdateGuidance, } from "../versionSkew"; -import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls"; +import { useAssetUrls } from "../assets/assetUrls"; const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; @@ -1331,6 +1331,7 @@ function ChatViewContent(props: ChatViewProps) { const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, + refresh: true, }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, @@ -1459,11 +1460,8 @@ function ChatViewContent(props: ChatViewProps) { const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; const videoPreviewRequestIdRef = useRef(0); - const videoPreviewAbortControllerRef = useRef(null); const cancelVideoPreviewRequest = useCallback(() => { videoPreviewRequestIdRef.current += 1; - videoPreviewAbortControllerRef.current?.abort(); - videoPreviewAbortControllerRef.current = null; }, []); const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -2542,14 +2540,8 @@ function ChatViewContent(props: ChatViewProps) { toastManager.add({ type: "error", title: "The environment is not connected." }); return; } - const videoMime = videoMimeType(attachment); - const isVideo = videoMime !== null; + const isVideo = videoMimeType(attachment) !== null; const action = isVideo ? "play" : "download"; - const videoPreviewAbortController = isVideo ? new AbortController() : null; - if (isVideo) { - videoPreviewAbortControllerRef.current?.abort(); - videoPreviewAbortControllerRef.current = videoPreviewAbortController; - } const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; const isCurrentRequest = () => !isVideo || @@ -2559,75 +2551,37 @@ function ChatViewContent(props: ChatViewProps) { videoPreviewRequestId, videoPreviewRequestIdRef.current, ); - const finishVideoPreviewRequest = () => { - if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { - setOpeningVideoAttachmentId(null); - videoPreviewAbortControllerRef.current = null; - } - }; if (isVideo) setOpeningVideoAttachmentId(attachment.id); - // fileName and mimeType ride in the signed claims so videos render - // inline while other files keep their real download name and type. - const result = await createAttachmentAssetUrl({ - environmentId, - input: { - resource: { - _tag: "attachment", - attachmentId: attachment.id, - fileName: attachment.name, - mimeType: videoMime ?? attachment.mimeType, - }, - }, - }); - if (!isCurrentRequest()) { - finishVideoPreviewRequest(); - return; - } - if (result._tag === "Failure") { - finishVideoPreviewRequest(); - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Could not " + action + " " + attachment.name, - description: error instanceof Error ? error.message : "The attachment is unavailable.", + try { + const url = await resolveFileAttachmentUrl({ + attachment, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, }); - return; - } - - const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); - if (!url) { - finishVideoPreviewRequest(); - toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name }); - return; - } - if (isVideo) { - try { - const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal); - if (!isCurrentRequest()) { - revokeBlobPreviewUrl(previewUrl); - return; - } + if (!isCurrentRequest()) return; + if (isVideo) { setExpandedImage({ - images: [{ src: previewUrl, name: attachment.name, type: "video" }], + images: [{ src: url, name: attachment.name, type: "video" }], index: 0, }); - } catch (error) { - if (!isCurrentRequest()) return; - toastManager.add({ - type: "error", - title: "Could not play " + attachment.name, - description: error instanceof Error ? error.message : "The attachment is unavailable.", - }); - } finally { - finishVideoPreviewRequest(); + return; } - return; + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = attachment.name; + anchor.click(); + } catch (error) { + if (!isCurrentRequest()) return; + toastManager.add({ + type: "error", + title: "Could not " + action + " " + attachment.name, + description: error instanceof Error ? error.message : "The attachment is unavailable.", + }); + } finally { + if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); } - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = attachment.name; - anchor.click(); }, [createAttachmentAssetUrl, environmentId, routeThreadKey], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cd69eceb7efd..d74f1f0d8e7f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -172,6 +172,7 @@ import { submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; function ComposerVideoThumbnail({ file }: { file: File }) { const setVideo = useCallback( @@ -189,9 +190,7 @@ function ComposerVideoThumbnail({ file }: { file: File }) { playsInline preload="metadata" aria-hidden="true" - onLoadedMetadata={(event) => { - event.currentTarget.currentTime = Math.min(0.1, event.currentTarget.duration || 0); - }} + onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)} className="pointer-events-none absolute inset-0 size-full object-cover" /> ); diff --git a/apps/web/src/components/chat/ExpandedImageDialog.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.test.tsx deleted file mode 100644 index c2a63f34ecde..000000000000 --- a/apps/web/src/components/chat/ExpandedImageDialog.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { ExpandedImageDialog } from "./ExpandedImageDialog"; -import type { ExpandedImagePreview } from "./ExpandedImagePreview"; - -describe("ExpandedImageDialog", () => { - it("renders video previews with native controls", () => { - const preview: ExpandedImagePreview = { - images: [ - { - src: "https://environment.test/api/assets/demo.mp4", - name: "demo.mp4", - type: "video", - }, - ], - index: 0, - }; - - const markup = renderToStaticMarkup( - {}} />, - ); - - expect(markup).toContain(" void; } +function ExpandedMediaFailure({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + export const ExpandedImageDialog = memo(function ExpandedImageDialog({ preview, onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); const [failedVideoSrc, setFailedVideoSrc] = useState(null); - const [downloadingVideoSrc, setDownloadingVideoSrc] = useState(null); - const [downloadFailedVideoSrc, setDownloadFailedVideoSrc] = useState(null); + const [failedImageSrc, setFailedImageSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; + const item = preview.images[index]; + const source: MediaActionSource = item?.actionsSource ?? { + kind: item?.type === "video" ? "video" : "image", + name: item?.name ?? "Media", + src: item?.src ?? null, + }; + const openFile = source.onOpenFile; + const actionsSource: MediaActionSource = openFile + ? { + ...source, + onOpenFile: () => { + openFile(); + onClose(); + }, + } + : source; const navigateImage = useCallback((direction: -1 | 1) => { setImageOffset((current) => current + direction); }, []); - const downloadVideo = async (src: string, name: string) => { - setDownloadFailedVideoSrc(null); - setDownloadingVideoSrc(src); - try { - await downloadVideoPreview(src, name); - } catch { - setDownloadFailedVideoSrc(src); - } finally { - setDownloadingVideoSrc((current) => (current === src ? null : current)); - } - }; - useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || isContextMenuOpen()) { + return; + } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -58,13 +81,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ return () => window.removeEventListener("keydown", onKeyDown); }, [navigateImage, onClose, preview.images.length]); - const item = preview.images[index]; if (!item) return null; const mediaLabel = item.type === "video" ? "video" : "image"; + const openOriginalLink = + item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null ? ( + + ) : null; - const isDownloadingVideo = downloadingVideoSrc === item.src; - const videoDownloadFailed = downloadFailedVideoSrc === item.src; - return ( + return createPortal(
)} -
- - {item.type === "video" && failedVideoSrc === item.src ? ( -
-

- {videoDownloadFailed - ? "Could not download this video." - : "This video format cannot be played here."} -

- -
- ) : item.type === "video" ? ( -
+ +
+ + {item.type === "video" && failedVideoSrc === item.src ? ( + +

This video could not be loaded or played.

+ +
+ ) : item.type === "video" ? ( +
+
{preview.images.length > 1 && ( )} -
+ , + document.body, ); }); diff --git a/apps/web/src/components/chat/ExpandedImagePreview.test.ts b/apps/web/src/components/chat/ExpandedImagePreview.test.ts index 71979a3cb013..f75ba86e4ec6 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.test.ts +++ b/apps/web/src/components/chat/ExpandedImagePreview.test.ts @@ -1,12 +1,46 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { ComposerFileAttachment } from "../../composerDraftStore"; import { attachVideoThumbnail, buildExpandedImagePreview, - downloadVideoPreview, + resolveMarkdownMediaPreview, } from "./ExpandedImagePreview"; +describe("resolveMarkdownMediaPreview", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + ["t3code:", "https:"], + ["t3code-dev:", "https:"], + ["http:", "http:"], + ["https:", "https:"], + ])( + "resolves protocol-relative media on %s without changing its source", + async (pageProtocol, mediaProtocol) => { + vi.stubGlobal("window", { location: { protocol: pageProtocol } }); + const source = "//cdn.example.com/recording.mp4?token=a%2FB&v=1#t=2"; + const preview = await resolveMarkdownMediaPreview({ + source, + createAssetUrl: async () => { + throw new Error("Remote media must not request a local asset URL."); + }, + }); + + expect(preview?.images[0]).toMatchObject({ + src: `${mediaProtocol}${source}`, + originalUrl: source, + actionsSource: { + src: `${mediaProtocol}${source}`, + reference: { kind: "url", url: source }, + }, + }); + }, + ); +}); + describe("buildExpandedImagePreview", () => { it("builds a video preview for a local video attachment", () => { const file = new File([new Uint8Array([1, 2, 3])], "demo.mp4", { type: "video/mp4" }); @@ -40,29 +74,4 @@ describe("buildExpandedImagePreview", () => { detach(); await expect(fetch(url)).rejects.toThrow(); }); - - it("downloads a video through a local blob URL", async () => { - vi.useFakeTimers(); - const source = URL.createObjectURL(new Blob([new Uint8Array([1, 2, 3])])); - const click = vi.fn(); - const anchor = { href: "", download: "", click }; - vi.stubGlobal("document", { createElement: () => anchor }); - - try { - await downloadVideoPreview(source, "demo.mp4"); - - expect(anchor.download).toBe("demo.mp4"); - expect(anchor.href).toMatch(/^blob:/); - expect(anchor.href).not.toBe(source); - expect(click).toHaveBeenCalledOnce(); - expect((await fetch(anchor.href)).ok).toBe(true); - - await vi.runAllTimersAsync(); - await expect(fetch(anchor.href)).rejects.toThrow(); - } finally { - URL.revokeObjectURL(source); - vi.useRealTimers(); - vi.unstubAllGlobals(); - } - }); }); diff --git a/apps/web/src/components/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx index b57a292e0bf5..cddfb95052b9 100644 --- a/apps/web/src/components/chat/ExpandedImagePreview.tsx +++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx @@ -1,10 +1,34 @@ import type { ComposerFileAttachment } from "../../composerDraftStore"; import { type ChatImageAttachment, isVideoAttachment } from "../../types"; +import type { + AssetCreateUrlResult, + AssetResource, + EnvironmentId, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; +import type { MediaActionSource } from "../media/MediaActions"; +import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; export interface ExpandedImageItem { src: string; name: string; type?: "video"; + autoPlay?: boolean; + /** Authored remote destination to open when embedding fails, never a generated asset URL. */ + originalUrl?: string; + actionsSource?: MediaActionSource; } export interface ExpandedImagePreview { @@ -12,23 +36,98 @@ export interface ExpandedImagePreview { index: number; } +/** Resolves a chat media reference on its owning environment, without downloading its bytes. */ +export async function resolveMarkdownMediaPreview(input: { + source: string; + resolvedFilePath?: string | undefined; + cwd?: string | undefined; + threadRef?: ScopedThreadRef | undefined; + httpBaseUrl?: string | undefined; + onOpenFile?: ((relativePath: string) => void) | undefined; + createAssetUrl: (input: { + environmentId: EnvironmentId; + input: { resource: AssetResource }; + }) => Promise>; +}): Promise { + const source = + input.resolvedFilePath === undefined + ? classifyMarkdownImageSource(input.source, input.cwd) + : { _tag: "WorkspaceFile" as const, path: input.resolvedFilePath }; + if (source._tag === "Blocked") return null; + + const path = + source._tag === "Direct" + ? source.uri.split(/[?#]/, 1)[0]! + : source.path.replace(/:\d+(?::\d+)?$/, ""); + const name = path.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = name.lastIndexOf("."); + const fileMimeType = + extensionIndex < 0 ? null : mediaMimeTypeFromExtension(name.slice(extensionIndex)); + const kind = + source._tag === "Direct" + ? mediaKindFromPath(source.uri) + : fileMimeType === null + ? null + : fileMimeType.startsWith("video/") + ? "video" + : "image"; + if (kind === null) return null; + + const reference = + source._tag === "Direct" ? mediaUrlReference(source.uri) : mediaFileReference(path, input.cwd); + const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; + let src: string; + let asset: MediaActionSource["asset"]; + if (source._tag === "Direct") { + src = resolveProtocolRelativeMediaUrl(source.uri); + } else { + if (!input.threadRef || !input.httpBaseUrl) { + throw new Error("Reconnect to this environment and open the media again."); + } + asset = { + environmentId: input.threadRef.environmentId, + resource: { _tag: "media-file", threadId: input.threadRef.threadId, path }, + }; + const result = await input.createAssetUrl({ + environmentId: asset.environmentId, + input: { resource: asset.resource }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const assetUrl = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (assetUrl === null) throw new Error("The environment returned an invalid media URL."); + src = assetUrl + markdownImageSourceFragment(input.source); + } + return { + images: [ + { + src, + name: name || kind, + ...(kind === "video" ? { type: "video", autoPlay: false } : {}), + ...(source._tag === "Direct" && resolveExternalWebLinkHost(source.uri) !== null + ? { originalUrl: source.uri } + : {}), + actionsSource: { + kind, + name: name || kind, + src, + ...(reference ? { reference } : {}), + ...(asset ? { asset } : {}), + ...(relativePath && input.onOpenFile + ? { onOpenFile: () => input.onOpenFile?.(relativePath) } + : {}), + }, + }, + ], + index: 0, + }; +} + export function attachVideoThumbnail(video: HTMLVideoElement, file: File): () => void { const url = URL.createObjectURL(file); video.src = url; return () => URL.revokeObjectURL(url); } -export async function downloadVideoPreview(src: string, name: string): Promise { - const response = await fetch(src); - if (!response.ok) throw new Error(`Could not download video (${response.status}).`); - const url = URL.createObjectURL(await response.blob()); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = name; - anchor.click(); - setTimeout(() => URL.revokeObjectURL(url), 30_000); -} - export function buildExpandedImagePreview( images: ReadonlyArray, selectedImageId: string, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index df944a98485e..2f9cd56c893c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2556,6 +2556,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { resource={viewedImage.resource} alt={viewedImage.alt} srcFragment={viewedImage.srcFragment} + workspaceRoot={workspaceRoot} style={{ maxHeight: "16rem" }} onImageExpand={onImageExpand} /> diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts index 9eb924280f8f..5761d4f95146 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.test.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -181,6 +181,8 @@ describe("external chat link context menu", () => { it.each([ ["https://example.com", "example.com"], ["http://localhost:3000/path", "localhost"], + ["//cdn.example.com/clip.mp4?signature=abc#t=2", "cdn.example.com"], + ["//", null], ["#details", null], ["mailto:hello@example.com", null], ["file:///tmp/example.txt", null], diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index b31f27668ade..d0f37f97d800 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -76,7 +76,7 @@ interface ShowExternalLinkContextMenuOptions { export function resolveExternalWebLinkHost(href: string | undefined): string | null { if (!href) return null; try { - const url = new URL(href); + const url = new URL(href.startsWith("//") ? `https:${href}` : href); if (url.protocol !== "http:" && url.protocol !== "https:") return null; return url.hostname || null; } catch { diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index f9ebb3076288..1272fa7d4974 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -4,7 +4,10 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; @@ -12,13 +15,16 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; -import { useAssetUrlState } from "~/assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; @@ -135,37 +141,53 @@ function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; + readonly workspaceRoot: string; readonly alt: string; readonly workspaceMutationId: string | null; }) { - const assetUrl = useAssetUrlState(props.environmentId, { - _tag: "workspace-file", - threadId: props.threadRef.threadId, - path: props.absolutePath, - }); + const resource = useMemo( + () => ({ + _tag: "workspace-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); const [failedUrl, setFailedUrl] = useState(null); const revisionSuffix = props.workspaceMutationId === null ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + const actionsSource: MediaActionSource = { + kind: "image", + name: props.alt, + src: imageUrl, + reference: mediaFileReference(props.absolutePath, props.workspaceRoot), + asset: { environmentId: props.environmentId, resource }, + }; if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { return ( -
- Unable to load workspace image. -
+ +
+ Unable to load workspace image. +
+
); } return assetUrl._tag === "Success" && imageUrl !== null ? (
- {props.alt} setFailedUrl(imageUrl)} - /> + + {props.alt} setFailedUrl(imageUrl)} + /> +
) : (
@@ -174,6 +196,60 @@ function WorkspaceImagePreview(props: { ); } +function WorkspaceVideoPreview(props: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef; + readonly absolutePath: string; + readonly workspaceRoot: string; + readonly name: string; + readonly workspaceMutationId: string | null; +}) { + const resource = useMemo( + () => ({ + _tag: "media-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); + useWorkspaceMutationRefresh({ + mutationId: props.workspaceMutationId, + resourceKey: JSON.stringify([props.environmentId, resource]), + refresh: () => { + // Failed refreshes flow through assetUrl and can be retried from the player. + void refreshAssetUrl().catch(() => undefined); + }, + }); + const revisionSuffix = + props.workspaceMutationId === null + ? "" + : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; + const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + + return ( +
+ +
+ ); +} + function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { @@ -791,8 +867,10 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); - const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); + const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); + const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); + const isMedia = isImage || isVideo; + const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping // it on the panel meant a thread switch dropped it and forced source back. @@ -816,7 +894,10 @@ export default function FilePreviewPanel({ (revealLine === null || (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); const canOpenInBrowser = - relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); + relativePath !== null && + !isVideo && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), @@ -824,7 +905,7 @@ export default function FilePreviewPanel({ ); const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); useWorkspaceMutationRefresh({ - enabled: relativePath !== null && !isImage && !selectedFilePending, + enabled: relativePath !== null && !isMedia && !selectedFilePending, mutationId: workspaceMutationId, refresh: file.refresh, resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, @@ -999,7 +1080,7 @@ export default function FilePreviewPanel({
) : null} - {relativePath && file.data?.truncated ? ( + {relativePath && !isMedia && file.data?.truncated ? (
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
@@ -1011,12 +1092,23 @@ export default function FilePreviewPanel({ relativePath ? "flex" : "hidden", )} > - {relativePath && isImage && absolutePath ? ( + {relativePath && isVideo && absolutePath ? ( + + ) : relativePath && isImage && absolutePath ? ( @@ -1099,7 +1191,7 @@ export default function FilePreviewPanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} - {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})} + {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})} /> ) : null} diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 08203ff6b87a..d02ec99605ba 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -4,6 +4,10 @@ import type { ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -178,9 +182,13 @@ export function useProjectFileQuery( relativePath: string | null, enabled = true, ): ProjectQueryState { - const atom = enabled - ? getProjectFileQueryAtom(environmentId, cwd, relativePath) - : EMPTY_PROJECT_FILE_QUERY_ATOM; + const isMedia = + relativePath !== null && + (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); + const atom = + enabled && !isMedia + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx new file mode 100644 index 000000000000..a67cadbca5a1 --- /dev/null +++ b/apps/web/src/components/media/MediaActions.tsx @@ -0,0 +1,189 @@ +import { + mediaReferenceFileName, + type MediaReference, +} from "@t3tools/client-runtime/media-reference"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useRef, useState, type ReactElement } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { readLocalApi } from "../../localApi"; +import { assetEnvironment } from "../../state/assets"; +import { readPreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { downloadMedia, readMediaPng } from "./mediaContent"; + +export interface MediaActionSource { + readonly kind: "image" | "video"; + readonly name: string; + readonly src: string | null; + readonly reference?: MediaReference; + readonly asset?: { readonly environmentId: EnvironmentId; readonly resource: AssetResource }; + readonly onOpenFile?: () => void; +} + +function mediaFileName(source: MediaActionSource): string { + return ( + (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind + ); +} + +/** Explicit byte operations get fresh capabilities without replacing a player's active source. */ +export function useMediaActions(source: MediaActionSource) { + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + const actionUrl = useCallback(async () => { + if (!source.asset) { + if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); + return source.src; + } + const { environmentId, resource } = source.asset; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("Reconnect to this environment and try again."); + const result = await createAssetUrl({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) throw new Error("The environment returned an invalid media URL."); + return url; + }, [source, createAssetUrl]); + const save = useCallback(async () => { + await downloadMedia(await actionUrl(), mediaFileName(source)); + }, [actionUrl, source]); + const copyImage = useCallback(async () => { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error( + "Image copying is unavailable. Use a secure browser connection or save the image.", + ); + } + // Start the clipboard write in the user gesture; fetching/decoding may finish later. + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), + ]); + }, [actionUrl]); + return { save, copyImage }; +} + +type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; + +/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ +export function MediaActions({ + source, + children, +}: { + source: MediaActionSource; + children: ReactElement; +}) { + const { save, copyImage } = useMediaActions(source); + const [tooltipOpen, setTooltipOpen] = useState(false); + const menuOpen = useRef(false); + const reference = source.reference; + const hasActions = + source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; + const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); + + const showMenu = async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api || menuOpen.current) return; + menuOpen.current = true; + setTooltipOpen(false); + let failureTitle = "Could not open media menu"; + let progressToast: ReturnType | undefined; + try { + const items: ContextMenuItem[] = []; + if (reference?.kind === "file") { + items.push({ id: "copy-full", label: "Copy full path" }); + if (reference.relativePath) + items.push({ id: "copy-relative", label: "Copy relative path" }); + } else if (reference?.kind === "url") { + items.push({ id: "copy-url", label: "Copy URL" }); + } + if (source.kind === "image") { + const unavailable = source.src === null && source.asset === undefined; + items.push({ id: "save", label: "Save image", disabled: unavailable }); + items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); + } + if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + + const action = await api.contextMenu.show(items, position); + if (!action) return; + failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; + const text = + action === "copy-full" && reference?.kind === "file" + ? reference.path + : action === "copy-relative" && reference?.kind === "file" + ? reference.relativePath + : action === "copy-url" && reference?.kind === "url" + ? reference.url + : undefined; + if (text !== undefined) { + await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL"); + toastManager.add({ + type: "success", + title: action === "copy-url" ? "URL copied" : "Path copied", + }); + } else if (action === "open-file") { + source.onOpenFile?.(); + } else if (action === "save" || action === "copy-image") { + progressToast = toastManager.add({ + type: "loading", + title: action === "save" ? "Preparing image download…" : "Copying image…", + }); + await (action === "save" ? save() : copyImage()); + toastManager.update(progressToast, { + type: "success", + title: action === "save" ? "Download started" : "Image copied", + }); + } + } catch (error) { + const toast = stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "The media action failed.", + }); + if (progressToast) toastManager.update(progressToast, toast); + else toastManager.add(toast); + } finally { + menuOpen.current = false; + } + }; + + return ( + + { + if (!hasActions || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu( + event.clientX === 0 && event.clientY === 0 + ? { x: bounds.left, y: bounds.bottom } + : { x: event.clientX, y: event.clientY }, + ); + }} + onKeyDown={(event) => { + if ( + !hasActions || + event.defaultPrevented || + !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) + ) + return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu({ x: bounds.left, y: bounds.bottom }); + }} + /> + + {tooltip} + + + ); +} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx new file mode 100644 index 000000000000..8f2d75680c14 --- /dev/null +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -0,0 +1,197 @@ +import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; + +import { cn } from "../../lib/utils"; +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +import { Button } from "../ui/button"; +import { OpenMediaLink } from "./OpenMediaLink"; +import { MediaActions, type MediaActionSource } from "./MediaActions"; + +interface MediaVideoPlayerProps { + readonly src: string | null; + readonly label: string; + readonly sourceFailed?: boolean | undefined; + readonly originalUrl?: string | undefined; + readonly revision?: string | null | undefined; + readonly preload?: "visible" | "metadata" | undefined; + readonly className?: string | undefined; + readonly videoClassName?: string | undefined; + readonly style?: CSSProperties | undefined; + readonly copyMarkdown?: string | undefined; + readonly onExpand?: ((src: string) => void) | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly actionsSource?: MediaActionSource | undefined; +} + +/** Keeps native range streaming and playback state consistent across inline and file previews. */ +export function MediaVideoPlayer({ + src: latestSrc, + label, + sourceFailed = false, + originalUrl, + revision = null, + preload = "visible", + className, + videoClassName, + style, + copyMarkdown, + onExpand, + onRetry, + actionsSource, +}: MediaVideoPlayerProps) { + const videoRef = useRef(null); + const [playbackSource, setPlaybackSource] = useState<{ + src: string; + revision: string | null; + } | null>(null); + const [failedSrc, setFailedSrc] = useState(null); + const [retrying, setRetrying] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); + const [preloadedSrc, setPreloadedSrc] = useState(null); + const src = playbackSource?.src ?? latestSrc; + const sourceRevision = playbackSource === null ? revision : playbackSource.revision; + const failed = src !== null ? failedSrc === src : sourceFailed; + + // Re-signing must not reset the playhead. Changed files refresh once playback pauses. + const refreshPausedRevision = useCallback(() => { + const video = videoRef.current; + if (video === null || video.paused || video.ended) { + setPlaybackSource((current) => + current !== null && current.revision !== revision ? null : current, + ); + } + }, [revision]); + useEffect(refreshPausedRevision, [refreshPausedRevision]); + + useEffect(() => { + const video = videoRef.current; + if (!video || preload === "metadata" || preloadedSrc === src) return; + if (typeof IntersectionObserver === "undefined") { + setPreloadedSrc(src); + return; + } + let active = true; + const observer = new IntersectionObserver( + (entries) => { + if (!active || !entries.some((entry) => entry.isIntersecting)) return; + setPreloadedSrc(src); + observer.disconnect(); + }, + { rootMargin: "200px" }, + ); + observer.observe(video); + return () => { + active = false; + observer.disconnect(); + }; + }, [src, preload, preloadedSrc, failed, loadAttempt]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const pauseWhenHidden = () => { + if (document.hidden) video.pause(); + }; + document.addEventListener("visibilitychange", pauseWhenHidden); + return () => { + document.removeEventListener("visibilitychange", pauseWhenHidden); + video.pause(); + }; + }, [src, failed, loadAttempt]); + + const retry = async () => { + if (retrying) return; + setRetrying(true); + try { + await onRetry?.(); + setPlaybackSource(null); + setFailedSrc(null); + setLoadAttempt((current) => current + 1); + } catch { + setFailedSrc(src); + } finally { + setRetrying(false); + } + }; + + const expandButton = + onExpand && src !== null ? ( + + ) : null; + + const player = ( + + {failed ? ( + + + + Video unavailable{label ? ` · ${label}` : ""} + + + {latestSrc !== null || onRetry ? ( + + ) : null} + + {expandButton} + + + ) : src !== null ? ( +