From 3625e429f809a038fa79424e7c87edf34b1e615a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 00:16:57 +0000 Subject: [PATCH 01/38] feat(web): redesign review panels and supporting pages --- .../src/pullRequest/PullRequestService.ts | 45 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 65 +- apps/server/src/vcs/GitVcsDriverCore.ts | 60 +- apps/web/src/browserFaviconStore.ts | 29 +- apps/web/src/components/AnimatedWidth.tsx | 40 - .../src/components/DiffPanel.browser.test.tsx | 4 +- apps/web/src/components/DiffPanel.tsx | 428 +++--- apps/web/src/components/DiffPanelShell.tsx | 3 +- .../src/components/DiffWorkerPoolProvider.tsx | 33 +- apps/web/src/components/PanelEmptyState.tsx | 42 + .../src/components/ThreadTerminalDrawer.tsx | 5 +- .../chat/RightPanelSurfaceContent.tsx | 12 +- .../components/chat/ThreadDetailsPrRow.tsx | 2 +- .../device/DevicePanel.browser.test.tsx | 160 ++- .../web/src/components/device/DevicePanel.tsx | 540 ++++++-- .../AnnotatableCodeView.browser.test.tsx | 2 +- .../components/diffs/AnnotatableCodeView.tsx | 18 +- .../diffs/DiffCommentAnnotation.tsx | 105 +- .../components/diffs/DiffFileHeaderParts.tsx | 81 ++ .../src/components/diffs/DiffReviewTray.tsx | 61 + .../src/components/diffs/DiffViewControls.tsx | 280 ++++ .../components/diffs/StyledDiffCodeView.tsx | 93 +- .../files/AttachmentFilePreview.tsx | 47 +- .../web/src/components/files/AudioPreview.tsx | 242 +++- .../src/components/files/FileBreadcrumbs.tsx | 12 +- .../src/components/files/FileBrowserPanel.tsx | 119 +- .../src/components/files/FileMediaDetails.tsx | 83 ++ .../files/FilePreviewPanel.browser.test.tsx | 1 + .../src/components/files/FilePreviewPanel.tsx | 242 ++-- .../src/components/files/LargeTextViewer.tsx | 13 +- .../components/files/VirtualTablePreview.tsx | 52 +- .../web/src/components/files/addFileToChat.ts | 33 + .../files/fileCommentAnnotations.ts | 2 + .../components/files/fileSurfaceChrome.tsx | 123 +- .../files/projectFilesQueryState.ts | 3 + .../src/components/media/MediaTransport.tsx | 225 ++++ .../media/MediaVideoPlayer.browser.test.tsx | 64 +- .../src/components/media/MediaVideoPlayer.tsx | 44 +- .../web/src/components/media/mediaFileInfo.ts | 66 + .../components/preview/AgentBrowserCursor.tsx | 4 +- .../src/components/preview/BrowserMockup.tsx | 23 - .../components/preview/PreviewEmptyState.tsx | 97 +- .../components/preview/PreviewFaviconIcon.tsx | 22 - .../preview/PreviewLocalServerCard.tsx | 35 +- .../preview/PreviewRecentUrlCard.tsx | 64 +- .../components/preview/PreviewUnreachable.tsx | 137 +- .../src/components/preview/PreviewView.tsx | 7 +- .../PullRequestActionsMenu.browser.test.tsx | 33 +- .../pullRequest/PullRequestActionsMenu.tsx | 108 +- .../pullRequest/PullRequestBaseBranch.tsx | 52 +- .../PullRequestCandidatePicker.tsx | 48 +- .../PullRequestCodeTab.browser.test.tsx | 12 +- .../pullRequest/PullRequestCodeTab.tsx | 531 +++----- .../pullRequest/PullRequestCommentForm.tsx | 3 +- .../PullRequestCompactFilterMenu.tsx | 16 +- .../pullRequest/PullRequestComposer.tsx | 135 +- .../pullRequest/PullRequestDetailActions.tsx | 692 +++++----- .../pullRequest/PullRequestDetailCrumbs.tsx | 105 +- .../pullRequest/PullRequestDetailLayout.tsx | 123 +- .../pullRequest/PullRequestDetailPanel.tsx | 718 +++++----- .../pullRequest/PullRequestGhosts.tsx | 349 +++-- .../pullRequest/PullRequestLabelPicker.tsx | 95 +- .../pullRequest/PullRequestListEmptyState.tsx | 216 +-- .../pullRequest/PullRequestListFilters.tsx | 127 +- .../pullRequest/PullRequestListGroups.tsx | 93 +- .../pullRequest/PullRequestListRow.tsx | 47 +- .../pullRequest/PullRequestReadinessCard.tsx | 567 ++++++++ .../PullRequestReviewAnnotation.tsx | 278 +++- .../pullRequest/PullRequestReviewForm.tsx | 144 +- .../pullRequest/PullRequestReviewerPicker.tsx | 59 +- .../components/pullRequest/PullRequestRow.tsx | 278 +++- .../pullRequest/PullRequestStackGroup.tsx | 332 +++++ .../pullRequest/PullRequestStackHeader.tsx | 18 +- .../PullRequestStackLayerContent.tsx | 67 +- .../pullRequest/PullRequestStackLayers.tsx | 69 +- .../pullRequest/PullRequestStackMenu.tsx | 174 +-- .../pullRequest/PullRequestStackPopover.tsx | 24 +- .../pullRequest/PullRequestSubmitKeyHint.tsx | 9 + .../PullRequestSummaryTab.browser.test.tsx | 201 ++- .../pullRequest/PullRequestSummaryTab.tsx | 576 ++++---- .../PullRequestTimelineTab.browser.test.tsx | 91 +- .../pullRequest/PullRequestTimelineTab.tsx | 1168 +++++------------ .../pullRequest/PullRequestTitleEditor.tsx | 27 +- .../pullRequest/PullRequestsColumn.tsx | 174 +-- .../pullRequest/PullRequestsPage.tsx | 154 ++- .../pullRequest/ThreadPullRequestsPanel.tsx | 37 +- .../pullRequest/guide/GuideExcerpt.tsx | 2 + .../pullRequestDetail.logic.test.ts | 223 +--- .../pullRequest/pullRequestDetail.logic.ts | 355 +++-- .../pullRequest/pullRequestList.logic.ts | 43 + .../pullRequest/pullRequestPresentation.tsx | 15 +- .../pullRequestsRouteSearch.test.ts | 13 +- .../useInlineActionConfirm.browser.test.tsx | 39 +- .../pullRequest/usePullRequestDetail.ts | 3 + apps/web/src/components/ui/group.tsx | 10 +- .../components/usage/UsageLimitsPooled.tsx | 88 +- apps/web/src/components/usage/UsagePage.tsx | 276 ++-- .../components/usage/UsagePriceOverrides.tsx | 29 +- apps/web/src/index.css | 5 + apps/web/src/pierre-tree-theme.ts | 32 + apps/web/src/reviewCommentContext.ts | 3 + apps/web/src/routes/_chat.pull-requests.tsx | 7 +- packages/contracts/src/git.ts | 12 + packages/contracts/src/pullRequest.ts | 46 + packages/shared/src/delimitedPreview.test.ts | 19 +- packages/shared/src/delimitedPreview.ts | 33 + .../src/server/PullRequestProvider.ts | 3 + .../src/server/GitHubPullRequestApi.test.ts | 79 +- .../src/server/GitHubPullRequestApi.ts | 21 +- .../src/server/GitHubPullRequestProvider.ts | 21 + .../src/server/gitHubPullRequestJson.test.ts | 107 ++ .../src/server/gitHubPullRequestJson.ts | 165 ++- .../src/server/pullRequestReads.graphql | 27 + .../src/server/pullRequestReads.graphql.ts | 80 +- .../src/server/pullRequestReviews.graphql | 57 +- .../src/server/pullRequestReviews.graphql.ts | 119 +- vite.config.ts | 14 +- 117 files changed, 8871 insertions(+), 4893 deletions(-) delete mode 100644 apps/web/src/components/AnimatedWidth.tsx create mode 100644 apps/web/src/components/PanelEmptyState.tsx create mode 100644 apps/web/src/components/diffs/DiffFileHeaderParts.tsx create mode 100644 apps/web/src/components/diffs/DiffReviewTray.tsx create mode 100644 apps/web/src/components/diffs/DiffViewControls.tsx create mode 100644 apps/web/src/components/files/FileMediaDetails.tsx create mode 100644 apps/web/src/components/files/addFileToChat.ts create mode 100644 apps/web/src/components/media/MediaTransport.tsx create mode 100644 apps/web/src/components/media/mediaFileInfo.ts delete mode 100644 apps/web/src/components/preview/BrowserMockup.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestReadinessCard.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestStackGroup.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestSubmitKeyHint.tsx diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 74da9bacee95d..19547cf459e1a 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -118,6 +118,15 @@ function supersedesReading(current: PullRequestReading | undefined, next: PullRe return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); } +// Retry a shared flight canceled by its previous reader after that flight leaves the cache. +const getShared = (cache: Cache.Cache, key: Key) => + Cache.get(cache, key).pipe( + Effect.catchCauseIf( + (cause) => Cause.hasInterruptsOnly(cause), + (_cause) => Cache.get(cache, key), + ), + ); + // Providers request one extra row; 99 stays within GitHub/GitLab's 100-row API limit. const DEFAULT_REPOSITORY_LIST_LIMIT = 99; const REPOSITORY_CONCURRENCY = 12; @@ -1236,10 +1245,10 @@ export const make = Effect.gen(function* () { const roots = viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); - if (options?.allowPaused === true) return Cache.get(viewerFlights, key); + if (options?.allowPaused === true) return getShared(viewerFlights, key); // Check pauses outside the shared lookup so background reads cannot spawn repeated failing CLIs and extend the pause. return rateLimits.check({ provider: api.kind, host }).pipe( - Effect.flatMap(() => Cache.get(viewerFlights, key)), + Effect.flatMap(() => getShared(viewerFlights, key)), Effect.catch((error) => Effect.succeed({ host, @@ -2003,6 +2012,9 @@ export const make = Effect.gen(function* () { : { overviewThreads: activity.overviewThreads }), commits: activity.commits, ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), + ...(activity.reviewRequests === undefined + ? {} + : { reviewRequests: activity.reviewRequests }), })), ), ), @@ -3213,13 +3225,7 @@ export const make = Effect.gen(function* () { ? null : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), ]); - // A replacement reader can join a lookup still finishing its previous reader's cancellation. - return Cache.get(listCache, key).pipe( - Effect.catchCauseIf( - (cause) => Cause.hasInterruptsOnly(cause), - (_cause) => Cache.get(listCache, key), - ), - ); + return getShared(listCache, key); }; const checksCache = yield* Cache.makeWith( @@ -3428,7 +3434,7 @@ export const make = Effect.gen(function* () { const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); // Skip reads older than strict summaries; caching them would preserve regressions without asking the host again. - const read = Cache.get(detailCache, key).pipe( + const read = getShared(detailCache, key).pipe( Effect.tap((value) => { if (value.state === "merged") noteMerged(input); const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); @@ -3498,7 +3504,7 @@ export const make = Effect.gen(function* () { return Cache.getSuccess(detailCache, key).pipe( Effect.flatMap( Option.match({ - onNone: () => Cache.get(previewCache, key), + onNone: () => getShared(previewCache, key), onSome: (detail) => Effect.succeed(previewFields(detail)), }), ), @@ -3506,7 +3512,7 @@ export const make = Effect.gen(function* () { }; const activity: PullRequestService["Service"]["activity"] = (input) => { const key = refCacheKey(input); - return Cache.get(activityCache, key); + return getShared(activityCache, key); }; const diffCache = yield* Cache.makeWith( @@ -3588,7 +3594,7 @@ export const make = Effect.gen(function* () { ), Effect.uninterruptible, ); - const read = Cache.get(diffCache, key).pipe( + const read = getShared(diffCache, key).pipe( Effect.ensuring(evictDiffs), Effect.ensuring(Effect.sync(armDiffSweep)), ); @@ -3613,7 +3619,7 @@ export const make = Effect.gen(function* () { const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => canonicalRef(input).pipe( Effect.flatMap((ref) => - Cache.get(filesViewedCache, JSON.stringify([refCacheKey(ref), filesViewedEpoch(ref)])), + getShared(filesViewedCache, JSON.stringify([refCacheKey(ref), filesViewedEpoch(ref)])), ), ); @@ -3660,12 +3666,7 @@ export const make = Effect.gen(function* () { } if (missing.size === 0) return { stats: held }; const key = statsBatchKey(missing.values()); - const { result, at } = yield* Cache.get(listStatsCache, key).pipe( - Effect.catchCauseIf( - (cause) => Cause.hasInterruptsOnly(cause), - (_cause) => Cache.get(listStatsCache, key), - ), - ); + const { result, at } = yield* getShared(listStatsCache, key); for (const [key, ref] of missing) { const stat = result.stats.find( (stat) => @@ -3952,9 +3953,9 @@ export const make = Effect.gen(function* () { notifyLinkedThreadsChanged, refreshAfterTurn, detail: credentialCached(detail), - checks: credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), + checks: credentialCached((input) => getShared(checksCache, refCacheKey(input))), watchFingerprint: credentialCached((input) => - Cache.get(watchFingerprintCache, refCacheKey(input)), + getShared(watchFingerprintCache, refCacheKey(input)), ), activity: credentialCached(activity), changes, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9e5b20205fefb..9445dd9f85cb3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5122,6 +5122,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { path: "HEAD", insertions: 1, deletions: 0, + status: "modified", }); }), ); @@ -5142,9 +5143,9 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const status = yield* driver.statusDetails(cwd); assert.deepEqual(status.workingTree.files, [ - { path: "café notes.md", insertions: 1, deletions: 0 }, - { path: "src/new.ts", insertions: 1, deletions: 0 }, - { path: "src/old.ts", insertions: 0, deletions: 0 }, + { path: "café notes.md", insertions: 1, deletions: 0, status: "modified" }, + { path: "src/new.ts", insertions: 1, deletions: 0, status: "renamed" }, + { path: "src/old.ts", insertions: 0, deletions: 0, status: "deleted" }, ]); yield* driver.commit(cwd, "Commit selection", "", { stage: { filePaths: status.workingTree.files.map((file) => file.path) }, @@ -5172,9 +5173,9 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const status = yield* driver.statusDetails(nestedCwd); assert.deepEqual(status.workingTree.files, [ - { path: "nested/new.txt", insertions: 0, deletions: 0 }, - { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, - { path: "README.md", insertions: 1, deletions: 0 }, + { path: "nested/new.txt", insertions: 0, deletions: 0, status: "untracked" }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0, status: "modified" }, + { path: "README.md", insertions: 1, deletions: 0, status: "modified" }, ]); // The root is what the paths above are relative to, not the nested cwd, and it keeps // the trailing space so the resolved editor target is the file that exists. @@ -5207,8 +5208,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const status = yield* driver.statusDetails(nestedCwd); assert.deepEqual(status.workingTree.files, [ - { path: "nested/new.txt", insertions: 0, deletions: 0 }, - { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + { path: "nested/new.txt", insertions: 0, deletions: 0, status: "untracked" }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0, status: "modified" }, ]); assert.equal( NodeFS.realpathSync.native(status.workingTree.rootPath!), @@ -5217,6 +5218,54 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports how each working-tree file changed", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "keep.ts", "keep\n"); + yield* writeTextFile(cwd, "remove.ts", "remove\n"); + yield* writeTextFile(cwd, "old name.ts", "rename me\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + yield* writeTextFile(cwd, "keep.ts", "keep\nedited\n"); + yield* git(cwd, ["rm", "-q", "remove.ts"]); + yield* git(cwd, ["mv", "old name.ts", "new name.ts"]); + yield* writeTextFile(cwd, "staged.ts", "staged\n"); + yield* git(cwd, ["add", "staged.ts"]); + yield* writeTextFile(cwd, "untracked.ts", "untracked\n"); + const blob = yield* git(cwd, ["rev-parse", "HEAD:keep.ts"]); + const conflicts = [ + ["AU", [2]], + ["UD", [1, 2]], + ["UA", [3]], + ["DU", [1, 3]], + ["AA", [2, 3]], + ["UU", [1, 2, 3]], + ] as const; + for (const [xy, stages] of conflicts) { + yield* writeTextFile(cwd, `${xy}.ts`, "conflicted\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).execute({ + operation: "test.unmergedIndex", + cwd, + args: ["update-index", "--index-info"], + stdin: stages.map((stage) => `100644 ${blob} ${stage}\t${xy}.ts\n`).join(""), + }); + } + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + const statuses = Object.fromEntries( + status.workingTree.files.map((file) => [file.path, file.status]), + ); + + assert.equal(statuses["keep.ts"], "modified"); + assert.equal(statuses["remove.ts"], "deleted"); + assert.equal(statuses["new name.ts"], "renamed"); + assert.equal(statuses["staged.ts"], "added"); + assert.equal(statuses["untracked.ts"], "untracked"); + for (const [xy] of conflicts) assert.equal(statuses[`${xy}.ts`], "modified", xy); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index da1794188b0b2..8489cbbf50012 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -35,6 +35,7 @@ import { type ReviewDiffFileStat, type ReviewDiffPreviewSource, type VcsRef, + type VcsWorkingTreeFileStatus, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -276,6 +277,13 @@ function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] { return files; } +function withFileStatus( + file: T, + status: VcsWorkingTreeFileStatus | null | undefined, +): T & { status?: VcsWorkingTreeFileStatus } { + return status ? { ...file, status } : file; +} + // Space-separated fields precede the path in each porcelain v2 record type. The path itself // may contain spaces, so it is everything after them. const PORCELAIN_V2_FIELDS_BEFORE_PATH: Record = { @@ -293,6 +301,30 @@ function parsePorcelainPath(record: string): string | null { return filePath.length > 0 ? filePath : null; } +function parsePorcelainEntry( + record: string, +): { path: string; status: VcsWorkingTreeFileStatus | null } | null { + const path = parsePorcelainPath(record); + if (path === null) return null; + const recordType = record[0]; + const xy = record.split(" ")[1] ?? ""; + const status: VcsWorkingTreeFileStatus | null = + recordType === "?" + ? "untracked" + : recordType === "!" + ? null + : recordType === "2" + ? "renamed" + : recordType === "u" + ? "modified" + : xy[1] === "D" || xy === "D." + ? "deleted" + : xy[0] === "A" || xy[1] === "A" + ? "added" + : "modified"; + return { path, status }; +} + function filterBranchesForListQuery( refs: ReadonlyArray, query?: string, @@ -2488,7 +2520,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let behindCount = 0; let aheadOfDefaultCount = 0; let hasWorkingTreeChanges = false; - const changedFilesWithoutNumstat = new Set(); + const changedFileStatuses = new Map(); const untrackedRoots: string[] = []; const statusRecords = statusStdout.split("\0"); @@ -2520,16 +2552,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (line.length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; - const pathValue = parsePorcelainPath(line); - if (pathValue) { - changedFilesWithoutNumstat.add(pathValue); - if (line.startsWith("? ")) untrackedRoots.push(pathValue); + const entry = parsePorcelainEntry(line); + if (entry) { + changedFileStatuses.set(entry.path, entry.status); + if (line.startsWith("? ")) untrackedRoots.push(entry.path); } - // A rename record is followed by its original path as a separate record. Listing it - // keeps the deletion selectable when committing only some files. + // Rename records carry a second NUL-delimited original path, selectable for partial commits. if (line.startsWith("2 ")) { const originalPath = statusRecords[++index]; - if (originalPath) changedFilesWithoutNumstat.add(originalPath); + if (originalPath && !changedFileStatuses.has(originalPath)) + changedFileStatuses.set( + originalPath, + line.split(" ")[1]?.includes("R") ? "deleted" : null, + ); } } } @@ -2580,12 +2615,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const files = Array.from(fileStatMap.entries()).map(([filePath, stat]) => { insertions += stat.insertions; deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + return withFileStatus( + { path: filePath, insertions: stat.insertions, deletions: stat.deletions }, + changedFileStatuses.get(filePath), + ); }); - for (const filePath of changedFilesWithoutNumstat) { + for (const [filePath, status] of changedFileStatuses) { if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); + files.push(withFileStatus({ path: filePath, insertions: 0, deletions: 0 }, status)); } files.sort((a, b) => a.path.localeCompare(b.path)); diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts index 8bafa69304f89..d6c8f317e9153 100644 --- a/apps/web/src/browserFaviconStore.ts +++ b/apps/web/src/browserFaviconStore.ts @@ -1,17 +1,15 @@ import { - parseScopedProjectKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, } from "@t3tools/client-runtime/environment"; import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { normalizeHostname } from "~/browser/browserTargetResolver"; -import { readThreadShell, useQuietThreadShell } from "~/state/entities"; -import { readPreparedConnection, usePreparedConnection } from "~/state/session"; +import { readThreadShell } from "~/state/entities"; +import { readPreparedConnection } from "~/state/session"; import { BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY, @@ -171,17 +169,6 @@ function mergeBrowserFaviconState( }; } -/** Server threads name their project in the shell; drafts in the composer draft store. */ -function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null { - const shell = useQuietThreadShell(threadRef); - const draftProjectRef = useComposerDraftStore((state) => { - const draft = state.getDraftSessionByRef(threadRef); - return draft ? scopedProjectKey(scopeProjectRef(draft.environmentId, draft.projectId)) : null; - }); - if (shell?.projectId) return scopeProjectRef(threadRef.environmentId, shell.projectId); - return draftProjectRef === null ? null : parseScopedProjectKey(draftProjectRef); -} - function readThreadProjectRef(threadRef: ScopedThreadRef): ScopedProjectRef | null { const projectId = readThreadShell(threadRef)?.projectId; if (projectId) return scopeProjectRef(threadRef.environmentId, projectId); @@ -284,18 +271,6 @@ function flushPendingFaviconsForThread( return Object.keys(remaining).length === 0; } -export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null { - const projectRef = useFaviconProjectRefForThread(threadRef); - const preparedConnection = usePreparedConnection(threadRef.environmentId); - const environmentHostname = Option.isSome(preparedConnection) - ? new URL(preparedConnection.value.httpBaseUrl).hostname - : null; - const threadKey = scopedThreadKey(threadRef); - return useBrowserFaviconStore((state) => - lookupFaviconForThread(state, threadKey, projectRef, url, environmentHostname), - ); -} - /** * A capture waiting for its thread's project still shows on that thread, and wins * over an older stored capture for the same origin. diff --git a/apps/web/src/components/AnimatedWidth.tsx b/apps/web/src/components/AnimatedWidth.tsx deleted file mode 100644 index dd2bfce7f0afd..0000000000000 --- a/apps/web/src/components/AnimatedWidth.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; - -import { observeResize } from "~/lib/observeResize"; -import { cn } from "~/lib/utils"; - -export function AnimatedWidth({ - children, - className, -}: { - readonly children: ReactNode; - readonly className?: string; -}) { - const contentRef = useRef(null); - const [width, setWidth] = useState(null); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes the content element's width before paint to animate width - useLayoutEffect(() => { - const element = contentRef.current; - if (!element) return; - return observeResize(element, (entries) => { - const nextWidth = Math.ceil(entries.at(-1)?.contentRect.width ?? 0); - if (nextWidth > 0) setWidth(nextWidth); - }); - }, []); - - return ( - - - {children} - - - ); -} diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index e10bd823889cc..a88701c2595f0 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -237,7 +237,9 @@ it.each([ await userEvent.click(selector); await expect.element(page.getByRole("option", { name: "main", exact: true })).toBeVisible(); await userEvent.click(page.getByRole("option", { name: recovery, exact: true })); - await expect.element(screen.getByText("No net changes in this selection.")).toBeVisible(); + await expect + .element(screen.getByText("This branch has no changes against its base.")) + .toBeVisible(); await expect.element(screen.getByText(errorMessage, { exact: true })).not.toBeInTheDocument(); const key = scopedThreadKey(context.threadRef); await vi.waitFor(() => { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index bf7bccca1f5d5..869a8c0a655c7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,4 +1,3 @@ -import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; import { @@ -6,6 +5,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { CHECKPOINT_DIFF_TRUNCATED_MARKER, type ReviewBranchCommitsResult, @@ -16,16 +16,14 @@ import { ArrowRightIcon, CheckIcon, ChevronDownIcon, - Columns2Icon, - FlaskConicalOffIcon, - FolderTreeIcon, - PilcrowIcon, - Rows3Icon, - TextWrapIcon, + FileCheckIcon, + FileDiffIcon, + FolderGit2Icon, + HistoryIcon, + TriangleAlertIcon, ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, } from "lucide-react"; + import * as Schema from "effect/Schema"; import { type MouseEvent, useEffect, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; @@ -54,7 +52,6 @@ import { useStableCallback } from "../hooks/useStableCallback"; import { buildFileDiffEntries, getDiffCollapseIconClassName, - getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -68,18 +65,19 @@ import { useQuietThreadShell, useQuietThreadVisibleTurnItems, } from "../state/entities"; -import { useClientSettings, updateClientSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; +import { useNowMinute } from "../hooks/useNowMinute"; import { formatRelativeTimeLabel, formatShortTimestamp } from "../timestampFormat"; import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; +import { PanelEmptyState } from "./PanelEmptyState"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { DiffFileTree } from "./diffs/DiffFileTree"; import { FileExplorerPane } from "./files/FileExplorerPane"; +import { DiffViewControls } from "./diffs/DiffViewControls"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { getDiffHeaderClickAction } from "./diffs/diffHeaderClick"; import { Button, InlineButton } from "./ui/button"; -import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { Combobox, @@ -119,6 +117,14 @@ import { useThreadRouteRef } from "../hooks/useThreadRouteTarget"; import { projectCommitUrl } from "@t3tools/client-runtime/change-request-links"; import { useOpenLink } from "../browser/useOpenLink"; import { Text } from "./ui/text"; +import { DiffReviewTray } from "./diffs/DiffReviewTray"; +import { + DiffFileBasename, + DiffFileChangeIcon, + DiffFileHeaderStat, + diffHeaderTitlePath, + fileDiffLineStat, +} from "./diffs/DiffFileHeaderParts"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -221,6 +227,7 @@ export default function DiffPanel({ visible, }: DiffPanelProps) { const resolvedTheme = useRenderedAppearance(); + const now = useNowMinute(); const settings = useClientSettings((settings) => ({ diffFilesCollapsed: settings.diffFilesCollapsed, diffIgnoreWhitespace: settings.diffIgnoreWhitespace, @@ -242,6 +249,24 @@ export default function DiffPanel({ const [baseRefQuery, setBaseRefQuery] = useState(""); // The open commit menu keeps the list it opened with, so rows do not move under the pointer. const [openCommitMenu, setOpenCommitMenu] = useState(null); + // The comments written here since the review tray was last dismissed, for its one-press Undo. + // Tied to the draft they went into, so switching threads does not carry a batch across. + const composerDraftKey = + typeof composerDraftTarget === "string" + ? composerDraftTarget + : scopedThreadKey(composerDraftTarget); + const [reviewBatch, setReviewBatch] = useState<{ + draftKey: string; + commentIds: ReadonlyArray; + } | null>(null); + const reviewBatchIds = reviewBatch?.draftKey === composerDraftKey ? reviewBatch.commentIds : []; + const addToReviewBatch = (commentId: string) => + setReviewBatch((current) => ({ + draftKey: composerDraftKey, + commentIds: + current?.draftKey === composerDraftKey ? [...current.commentIds, commentId] : [commentId], + })); + const dismissReviewBatch = () => setReviewBatch(null); const routeThreadRef = useThreadRouteRef(); const activeThreadId = routeThreadRef?.threadId ?? null; @@ -531,16 +556,6 @@ export default function DiffPanel({ ? Math.max(0, recordedToolCallFileCount - renderablePatch.files.length) : 0; const fileStats = new Map(lazySource?.files?.map((file) => [file.path, file])); - const sourceLineStat = - isGitSelection && selectedGitSource?.files - ? selectedGitSource.files.reduce( - (total, file) => ({ - additions: total.additions + file.additions, - deletions: total.deletions + file.deletions, - }), - { additions: 0, deletions: 0 }, - ) - : null; const currentLoadDiffFiles = isGitSelection && activeThread && preview && selectedGitSource && canReadFiles ? createGitDiffFileContentsLoader(getDiffFileContents, { @@ -696,7 +711,6 @@ export default function DiffPanel({ (file) => file.cacheKey?.endsWith(":pending") && isFileShown(file), ); const allDiffFilesCollapsed = areAllDiffFilesCollapsed(shownFileKeys, collapsedDiffFileKeys); - const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); const fileTreeEntries = diffFileTreeEntries(renderableFiles.filter(isFileShown)); const codeViewReveal = useCodeViewFileReveal(); // Lazy files that sat above the saved position load again only when asked for. @@ -843,7 +857,7 @@ export default function DiffPanel({
} + render={ + ) : null + } + /> + ) : hasNoNetChanges ? ( + } + title="No changes" + description={ + selectedTurn + ? "This turn left every file as it was." + : selectedGitScope === "unstaged" + ? "The working tree matches the last commit." + : selectedGitScope === "commit" + ? "This commit has no changes." + : "This branch has no changes against its base." + } + /> ) : ( -
-

- {hasNoNetChanges - ? "No net changes in this selection." - : "No patch available for this selection."} -

-
+ } + title="No patch available" + description="There is nothing to show for this selection." + /> ) ) : lazySource || renderablePatch?.kind === "files" ? (
@@ -1406,16 +1318,15 @@ export default function DiffPanel({ className="min-h-0 min-w-0 flex-1" onClickCapture={(event) => { const action = getDiffHeaderClickAction(event.nativeEvent); - if (action?.type === "open") openDiffFile(action.filePath); - else if (action?.type === "toggle") toggleDiffFileCollapsed(action.fileKey); + if (!action) return; + // The redesign's basename in the header prefix names its file for Open. + const filePath = diffHeaderTitlePath(event.nativeEvent.composedPath()); + if (filePath) openDiffFile(filePath); + else if (action.type === "toggle") toggleDiffFileCollapsed(action.fileKey); }} onContextMenuCapture={(event) => { const composedPath = event.nativeEvent.composedPath?.() ?? []; - const title = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-title"), - ); - const filePath = title?.textContent?.trim(); + const filePath = diffHeaderTitlePath(composedPath)?.trim(); if (!filePath) return; event.preventDefault(); onFileContextMenu( @@ -1443,6 +1354,7 @@ export default function DiffPanel({ sectionId={reviewSectionId} sectionTitle={reviewSectionTitle} composerDraftTarget={composerDraftTarget} + onCommentAdded={addToReviewBatch} renderHeaderFilenameSuffix={(fileDiff) => { const path = resolveFileDiffPath(fileDiff); const state = fileStates.get(path); @@ -1457,38 +1369,37 @@ export default function DiffPanel({ /> ); }} - {...(lazySource - ? { - unsafeCSSExtra: - "[data-additions-count], [data-deletions-count] { display: none; }", - renderHeaderMetadata: (fileDiff: FileDiffMetadata) => { - const stat = fileStats.get(resolveFileDiffPath(fileDiff)); - return stat ? ( - - ) : null; - }, - } - : {})} + renderHeaderMetadata={(fileDiff) => { + // A lazily loaded file's hunks may not be here yet; the scope's stats are. + const stat = lazySource + ? fileStats.get(resolveFileDiffPath(fileDiff)) + : fileDiffLineStat(fileDiff); + return stat ? ( + + ) : null; + }} onFileCollapsedChange={setDiffFileCollapsed} renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { + const filePath = resolveFileDiffPath(fileDiff); + // One flex row: the slot wraps it in a block div, and bare svgs would stack. return ( - requestFile(resolveFileDiffPath(fileDiff)), - } - : {})} - /> + + requestFile(filePath), + } + : {})} + /> + + + ); }} options={{ @@ -1540,6 +1451,11 @@ export default function DiffPanel({
)} + )} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 611e2728ad1b1..99d4f2e5ee7ec 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -10,7 +10,8 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; return cn( - "flex items-center justify-between gap-2", + // diff-toolbar: DiffPanel folds its actions into a menu in narrow panes. + "@container/diff-toolbar flex items-center justify-between gap-2", mode === "embedded" ? "px-2" : "px-4", shouldUseDragRegion ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:pr-(--workspace-native-controls-inset)" diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index a40d8e20c7bac..04055f151264f 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -27,11 +27,21 @@ let sharedWorkerPool: | { readonly pool: WorkerPoolManager; readonly unsubscribeTheme: () => void; + failed: boolean; + readonly listeners: Set<() => void>; consumers: number; idleTimer: ReturnType | undefined; } | undefined; +// A failed worker must release waiting viewers to main-thread highlighting. +function failDiffWorkerPool(entry: NonNullable) { + if (entry.failed || entry.pool.isInitialized()) return; + entry.failed = true; + entry.pool.terminate(); + for (const listener of entry.listeners) listener(); +} + function applyDiffWorkerTheme(pool: WorkerPoolManager) { const themeName = resolveDiffThemeName(getRenderedAppearance()); let operation: DiffWorkerError["operation"] = "get-render-options"; @@ -55,7 +65,9 @@ function createDiffWorkerPool(poolSize: number) { { workerFactory: () => { try { - return new DiffsWorker(); + const worker = new DiffsWorker(); + worker.addEventListener("error", () => failDiffWorkerPool(entry), { once: true }); + return worker; } catch (cause) { throw new DiffWorkerError({ operation: "create-worker", themeName, cause }); } @@ -70,12 +82,15 @@ function createDiffWorkerPool(poolSize: number) { useTokenTransformer: true, }, ); - return { + const entry: NonNullable = { pool, + failed: false, + listeners: new Set(), unsubscribeTheme: subscribeRenderedAppearance(() => applyDiffWorkerTheme(pool)), consumers: 0, idleTimer: undefined, }; + return entry; } function acquireDiffWorkerPool(poolSize: number) { @@ -87,12 +102,12 @@ function acquireDiffWorkerPool(poolSize: number) { } // Plain-text views do not queue a highlight task that could retry a blank first render. -function DiffWorkerReady({ children }: { children?: ReactNode }) { +function DiffWorkerReady({ failed, children }: { failed: boolean; children?: ReactNode }) { const workerPool = useWorkerPool(); const [readyPool, setReadyPool] = useState(); const ready = workerPool ? readyPool === workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool() - : typeof window === "undefined"; + : failed || typeof window === "undefined"; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- initializes the diff worker pool and marks it ready when its async startup settles useEffect(() => { @@ -113,7 +128,7 @@ function DiffWorkerReady({ children }: { children?: ReactNode }) { ) : (
Loading code...
@@ -125,8 +140,10 @@ function DiffWorkerReady({ children }: { children?: ReactNode }) { function subscribeSharedWorkerPool(onStoreChange: () => void) { if (typeof window === "undefined") return () => {}; const entry = acquireDiffWorkerPool(resolveDiffWorkerPoolSize()); + entry.listeners.add(onStoreChange); onStoreChange(); return () => { + entry.listeners.delete(onStoreChange); entry.consumers -= 1; if (entry.consumers !== 0) return; entry.idleTimer = setTimeout(() => { @@ -141,7 +158,7 @@ function subscribeSharedWorkerPool(onStoreChange: () => void) { export function useDiffWorkerPool() { return useSyncExternalStore( subscribeSharedWorkerPool, - () => sharedWorkerPool?.pool, + () => (sharedWorkerPool?.failed ? null : sharedWorkerPool?.pool), () => undefined, ); } @@ -150,8 +167,8 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { const workerPool = useDiffWorkerPool(); return ( - - {children} + + {children} ); } diff --git a/apps/web/src/components/PanelEmptyState.tsx b/apps/web/src/components/PanelEmptyState.tsx new file mode 100644 index 0000000000000..47b1219d0d3f8 --- /dev/null +++ b/apps/web/src/components/PanelEmptyState.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +/** + * What a right panel surface shows when it has nothing to render: a small glyph, one title, a + * line of explanation and at most one way forward. The diff panel and the thread's pull request + * list use it for empty, unavailable and failed states so they read like the pull request page's + * "nothing matches" state rather than as loose captions. + */ +export function PanelEmptyState({ + icon, + title, + description, + action, + tone = "default", +}: { + icon: ReactNode; + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + tone?: "default" | "error"; +}) { + return ( +
+ + {icon} + +

{title}

+ {description ? ( +

{description}

+ ) : null} + {action ?
{action}
: null} +
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d8dddcf6d2c23..6631054a46a25 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1799,8 +1799,9 @@ export default function ThreadTerminalDrawer({
diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 5ba6985e19d21..dcc563772ac81 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -11,6 +11,8 @@ import type { import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { lazy, Suspense } from "react"; import SideChatPane from "./SideChatPane"; +import { FolderX } from "lucide-react"; +import { FileSurfaceState } from "../files/fileSurfaceChrome"; import type { ComposerImageAttachment, DraftId } from "../../composerDraftStore"; import type { ShortcutMatchContext } from "../../keybindings"; import type { SideChatParentPanel } from "../../lib/sideChatPane"; @@ -204,7 +206,15 @@ export function RightPanelSurfaceContent(props: { case "file": { const files = props.files; const attachment = surface.kind === "file" ? surface.attachment : undefined; - if (!attachment && (files.projectName === null || !files.workspaceRoot)) return null; + if (!attachment && (files.projectName === null || !files.workspaceRoot)) { + return ( + } + title="No project folder" + description="This thread has no workspace folder, so there are no files to show." + /> + ); + } return ( - {describePullRequestChecks(detail.checks)} + {describePullRequestChecks(detail.checks)[0]} } /> diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index 2212d96927255..2500e166db0fc 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -20,6 +20,7 @@ type CommandResult = const mocks = vi.hoisted(() => ({ open: vi.fn<(request: unknown) => Promise>(), close: vi.fn<(request: unknown) => Promise>(), + shutdown: vi.fn<(request: unknown) => Promise>(), list: vi.fn<(request: unknown) => Promise>(), openDevice: vi.fn(), closeSurface: vi.fn(), @@ -67,7 +68,7 @@ vi.mock("~/state/device", async () => { const Effect = await import("effect/Effect"); const { AsyncResult, Atom } = await import("effect/reactivity"); return { - deviceEnvironment: { list: "list", open: "open", close: "close" }, + deviceEnvironment: { list: "list", open: "open", close: "close", shutdown: "shutdown" }, deviceHostProbeAtom: Atom.family((environmentId: string) => Atom.make(() => Effect.promise(() => mocks.list({ environmentId, input: {} }))), ), @@ -83,7 +84,9 @@ vi.mock("~/state/use-atom-command", () => ({ ? mocks.close : command === "list" ? mocks.list - : () => Promise.resolve({ _tag: "Success", value: {} }), + : command === "shutdown" + ? mocks.shutdown + : () => Promise.resolve({ _tag: "Success", value: {} }), })); vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" })); vi.mock("~/rightPanelStore", () => ({ @@ -160,6 +163,159 @@ function setup() { container.style.height = "800px"; } +describe("DevicePanel notices", () => { + it.each(["ios", "android"] as const)( + "leases a Simlock %s device and returns the opened lease to the pool", + async (platform) => { + setup(); + currentState = { + ...deviceState, + hosts: [ + { + id: "pool", + kind: "simlock", + label: "Device pool", + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: true }, + ], + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatuses: { pool: { status: "ready" } }, + devices: [{ ...phone, hostId: "pool", id: `simlock:${platform}:Phone`, platform }], + sessions: [], + }; + mocks.open.mockResolvedValue({ + _tag: "Success", + value: { hostId: "pool", deviceId: "lease-1" }, + }); + mocks.shutdown.mockResolvedValue({ + _tag: "Success", + value: { hostId: "pool", deviceId: "lease-1" }, + }); + const readyState = currentState; + const refused = "The Simlock gateway refused the agent token for Device pool."; + currentState = { + ...readyState, + hosts: readyState.hosts.map((host) => ({ + ...host, + platforms: host.platforms.map((entry) => ({ + ...entry, + available: false, + reason: refused, + })), + })), + devices: [], + }; + await using screen = await render(devicePanel(threadA), { container }); + expect(container.textContent?.split(refused)).toHaveLength(2); + const missingPlatform = platform === "ios" ? "Android" : "iOS"; + const emptyPool = `The pool has no ${missingPlatform} devices.`; + currentState = { + ...readyState, + hosts: readyState.hosts.map((host) => ({ + ...host, + platforms: host.platforms.map((entry) => + entry.platform === platform ? entry : { ...entry, available: false, reason: emptyPool }, + ), + })), + }; + await screen.rerender(devicePanel(threadA)); + expect(container.textContent).toContain(emptyPool); + expect(container.textContent).not.toContain(refused); + currentState = readyState; + await screen.rerender(devicePanel(threadA)); + expect(container.textContent).not.toContain(emptyPool); + expect(container.textContent).toContain("Device pool leases devices from a Simlock pool."); + expect(container.textContent).toContain("Returning a device to the pool wipes it."); + expect(container.textContent).not.toContain("Xcode"); + expect(container.textContent).not.toContain("Android Studio"); + await click(container.querySelector('[aria-label="Lease Phone"]')); + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(threadA, { + hostId: "pool", + deviceId: "lease-1", + platform, + name: "Phone", + }); + currentState = { + ...currentState, + devices: [{ ...phone, hostId: "pool", id: "lease-1", platform, booted: true }], + }; + await screen.rerender(devicePanel(threadA)); + await click( + container.querySelector('[aria-label="Return to pool Phone"]'), + ); + expect(mocks.shutdown).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadA.environmentId, + input: { hostId: "pool", deviceId: "lease-1", platform }, + }); + expect(mocks.close).not.toHaveBeenCalled(); + }, + ); + + it.each(["ios", "android"] as const)("opens an EAS %s cloud session", async (platform) => { + setup(); + currentState = { + ...deviceState, + hosts: [ + { + id: "expo", + kind: "eas", + label: "Expo", + platforms: (["ios", "android"] as const).map((candidate) => ({ + platform: candidate, + available: candidate === platform, + ...(candidate !== platform + ? { reason: "This platform is temporarily unavailable." } + : {}), + })), + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatuses: { expo: { status: "ready" } }, + devices: [{ ...phone, hostId: "expo", id: `eas-${platform}`, platform }], + sessions: [], + }; + mocks.open.mockResolvedValue({ + _tag: "Success", + value: { hostId: "expo", deviceId: "eas-session" }, + }); + await using screen = await render(devicePanel(threadA), { container }); + const devices = platform === "ios" ? "iOS Simulators" : "Android emulators"; + expect(container.textContent).toContain( + `Expo starts ${devices} in the cloud with EAS Simulator.`, + ); + expect(container.textContent).toContain("This platform is temporarily unavailable."); + expect(container.textContent).not.toContain("Xcode"); + expect(container.textContent).not.toContain("Android Studio"); + await click(startButton()); + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(threadA, { + hostId: "expo", + deviceId: "eas-session", + platform, + name: "Phone", + }); + currentState = { + ...currentState, + hosts: currentState.hosts.map((host) => ({ + ...host, + platforms: host.platforms.map((entry) => ({ platform: entry.platform, available: true })), + })), + }; + await screen.rerender(devicePanel(threadA)); + expect(container.textContent).toContain( + "Expo starts iOS Simulators and Android emulators in the cloud with EAS Simulator.", + ); + expect(container.textContent).not.toContain("temporarily unavailable"); + expect(container.textContent).not.toContain("Xcode"); + expect(container.textContent).not.toContain("Android SDK"); + expect(container.textContent).not.toContain("Android Studio"); + }); +}); + describe("DevicePanel thread switches", () => { it("keeps the latest pick pending across switches away and back", async () => { setup(); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 18150f0b6651a..9520568aea37c 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -1,14 +1,28 @@ -import { Tooltip, TooltipTrigger, TooltipPopup } from "~/components/ui/tooltip"; import { DeviceHostUpdates } from "./DeviceHostUpdates"; import type { + DeviceHostSummary, DevicePlatform, DeviceServiceState, DeviceSummary, ScopedThreadRef, } from "@t3tools/contracts"; -import { ArrowUpRight, CircleAlert, Play, Smartphone, X } from "lucide-react"; import { useAtomMount } from "@effect/atom-react"; -import { type ReactNode, useState } from "react"; +import { Schema } from "effect"; +import { + ArrowUpRight, + CloudIcon, + Info, + Monitor, + Play, + Power, + RotateCw, + Server, + Smartphone, + Tablet, + TriangleAlert, + X, +} from "lucide-react"; +import { useState } from "react"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { @@ -16,8 +30,27 @@ import { useRightPanelStore, type RightPanelSurface, } from "~/rightPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/components/ui/empty"; +import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { DiscoveryList } from "~/components/ui/discovery-list"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Switch } from "~/components/ui/switch"; import { Dialog } from "~/components/ui/dialog"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { WizardPopup } from "~/components/ui/wizard"; @@ -48,6 +81,9 @@ const platformLabel = (platform: DevicePlatform) => const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; +const FLOAT_OVER_CHAT_STORAGE_KEY = "t3code:devices:float-over-chat"; +const DISMISSED_AVAILABILITY_STORAGE_KEY = "t3code:devices:dismissed-availability"; + export function DevicePanel(props: { readonly mode: PreviewPanelMode; readonly threadRef: ScopedThreadRef; @@ -60,16 +96,34 @@ export function DevicePanel(props: { const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); + const shutdown = useAtomCommand(deviceEnvironment.shutdown); // The panel stays mounted across thread switches, so an in-flight open or // power-off is tagged with the thread it belongs to and only shown there. const scope = `${environmentId}\u0000${threadId}`; const [operation, setOperation] = useState<{ readonly scope: string; readonly pending: DeviceSummary | null; + readonly poweringOffKey: string | null; readonly error: string | null; } | null>(null); const pendingDevice = operation?.scope === scope ? operation.pending : null; const operationError = operation?.scope === scope ? operation.error : null; + const poweringOffKey = operation?.scope === scope ? operation.poweringOffKey : null; + const [selectedHostId, setSelectedHostId] = useState(null); + // Persistent: when on, a device opened from this list floats over chat + // instead of taking the panel. The workspace's own Float action still works. + const [floatOverChat, setFloatOverChat] = useLocalStorage( + FLOAT_OVER_CHAT_STORAGE_KEY, + false, + Schema.Boolean, + ); + // Holds the text of the last dismissed info alert, so a change in what the + // host can run brings the alert back. + const [dismissedAvailability, setDismissedAvailability] = useLocalStorage( + DISMISSED_AVAILABILITY_STORAGE_KEY, + "", + Schema.String, + ); const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const hostDisabled = state.hostStatus === "disabled"; @@ -97,15 +151,21 @@ export function DevicePanel(props: { ? state.hosts.find((host) => host.id === activeDevice.hostId) : undefined; - const grouped = groupDevices(state); + const selectedHost = + state.hosts.find((host) => host.id === selectedHostId) ?? + state.hosts.find((host) => state.hostStatuses[host.id]?.status === "ready") ?? + state.hosts[0] ?? + null; + const selectedHostKey = selectedHost?.id ?? null; + const grouped = groupDevices(state, selectedHostKey); - const startOperation = (pending: DeviceSummary | null) => { - const started = { scope, pending, error: null }; + const startOperation = (pending: DeviceSummary | null, poweringOffKey: string | null = null) => { + const started = { scope, pending, poweringOffKey, error: null }; setOperation(started); // Returning to the same thread can start a newer operation before this one settles. return (error: string | null) => setOperation((current) => - current === started ? { ...started, pending: null, error } : current, + current === started ? { ...started, pending: null, poweringOffKey: null, error } : current, ); }; @@ -122,19 +182,36 @@ export function DevicePanel(props: { platform: device.platform, }, }); - if (result._tag === "Failure") settleOperation(formatEnvironmentQueryError(result.cause)); - else { - // The server opened the device for the starting thread, so its tab opens there. - useRightPanelStore.getState().openDevice(props.threadRef, { + if (result._tag === "Failure") { + settleOperation(formatEnvironmentQueryError(result.cause)); + } else { + const target = { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, name: device.name, - }); + }; + if (floatOverChat) { + usePreviewMiniPlayerStore.getState().open(props.threadRef, { kind: "device", ...target }); + useRightPanelStore.getState().close(props.threadRef); + } else { + useRightPanelStore.getState().openDevice(props.threadRef, target); + } settleOperation(null); } }; + const powerOffDevice = (device: DeviceSummary) => { + const key = deviceKey(device); + const settleOperation = startOperation(null, key); + void shutdown({ + environmentId, + input: { hostId: device.hostId, deviceId: device.id, platform: device.platform }, + }).then((result) => { + settleOperation(result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null); + }); + }; + const floatActive = () => { if (!activeDevice) return; usePreviewMiniPlayerStore.getState().open(props.threadRef, { @@ -195,11 +272,19 @@ export function DevicePanel(props: { ] : []; }); - const unavailablePlatforms = state.hosts.flatMap((host) => - host.platforms - .filter((platform) => !platform.available) - .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })), - ); + const availability = selectedHost + ? describeHostAvailability( + selectedHost, + state.devices.some( + (device) => device.hostId === selectedHost.id && device.platform === "android", + ), + state.agentAccessEnabled, + isAndroidDiscoveryUncertain(state), + ) + : null; + const availabilityKey = availability + ? [availability.title, ...availability.details].join("\n") + : ""; if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( @@ -244,7 +329,25 @@ export function DevicePanel(props: {
) : null} {operationError ? ( - setOperation(null)}>{operationError} +
+

+ {operationError} +

+ +
) : null}
@@ -299,102 +402,136 @@ export function DevicePanel(props: { ) : null} ) : ( -
-
- {grouped.length === 0 ? ( - <> - -

+

+
+ {selectedHost ? ( + + ) : null} +
+ +
+ {grouped.length === 0 ? ( + + + + {state.hostStatus === "failed" ? : } + + + {state.hostStatus === "failed" + ? "Device hub didn't start" + : "No devices found"} + + + {state.hostStatus === "failed" + ? (state.hostStatusDetail ?? "The device hub failed to start.") + : "No simulators or emulators were found on this environment."} + + + {loaded && !hostBusy ? ( + + + + ) : null} + ) : null} - {hostReady && grouped.length > 0 ? ( -
- {grouped.map((group) => ( -
-
- -

{platformLabel(group.platform)}

-
+ {hostReady && grouped.length > 0 + ? grouped.map((group) => ( +
+

+ {platformLabel(group.platform)} +

{group.devices.map((device) => ( -
- - - -
- - {device.name} - - - - }> - {device.version} - - - - - { - state.hosts.find((host) => host.id === device.hostId) - ?.label - } - - {device.booted ? "Running" : "Stopped"} - - - - -
- -
+ device={device} + simlock={selectedHost?.kind === "simlock"} + pending={ + pendingDeviceKey === deviceKey(device) || + poweringOffKey === deviceKey(device) + } + disabled={pendingDeviceKey !== null || poweringOffKey !== null} + onOpen={() => void selectDevice(deviceKey(device))} + onPowerOff={() => powerOffDevice(device)} + /> ))}
- ))} -
- ) : null} + )) + : null} {hostReady && - !isAndroidDiscoveryUncertain(state) && - !state.devices.some((device) => device.platform === "android") && - !unavailablePlatforms.some((platform) => platform.platform === "android") ? ( -

- No Android virtual devices found. Create one in Android Studio's Device Manager, - then refresh. -

+ availability && + !(availability.available && dismissedAvailability === availabilityKey) ? ( + + {availability.available ? : } + {availability.title} + {availability.details.length > 0 ? ( + {availability.details.join(" ")} + ) : null} + {availability.available ? ( + + + + ) : null} + ) : null} - {loaded && !hostBusy ? ( + {loaded && !hostBusy && grouped.length > 0 ? ( + + {device.physical + ? "Connected" + : device.booted + ? "Running" + : props.simlock + ? "Available" + : "Shut down"} + + {props.pending ? ( + + ) : device.physical ? ( + + ) : device.booted ? ( + + ) : ( + + )}
); } @@ -452,22 +651,17 @@ function DeviceDiscoveryWarning(props: { platform === "ios" ? "iOS" : "Android", ); return ( -
- - - - {platforms.length > 0 - ? `${platforms.join(" and ")} ${platforms.length > 1 ? "lists" : "list"} on ${props.label} may be incomplete.` - : `Device discovery on ${props.label} reported a warning.`} - - - + + + + {platforms.length > 0 + ? `${platforms.join(" and ")} ${platforms.length > 1 ? "lists" : "list"} on ${props.label} may be incomplete.` + : `Device discovery on ${props.label} reported a warning.`} + + {platforms.length > 0 ? ( - -
+ + ); } -function groupDevices(state: DeviceServiceState) { +const platformToolLabel = (platform: DevicePlatform) => + platform === "ios" ? "Xcode and iOS Simulator" : "The Android SDK and Emulator"; + +/** The availability alert under the list: what this host can run and why not. */ +function describeHostAvailability( + host: DeviceHostSummary, + hasAndroidDevices: boolean, + agentAccessEnabled: boolean, + androidDiscoveryUncertain: boolean, +) { + const ios = host.platforms.find((platform) => platform.platform === "ios"); + const android = host.platforms.find((platform) => platform.platform === "android"); + const available = host.platforms.filter((platform) => platform.available); + if (host.kind === "simlock") { + return { + available: available.length > 0, + title: `${host.label} leases devices from a Simlock pool.`, + details: [ + "Returning a device to the pool wipes it.", + ...new Set( + host.platforms.flatMap((platform) => + !platform.available && platform.reason ? [platform.reason] : [], + ), + ), + ], + }; + } + const details: string[] = []; + const first = available[0]; + const cloudDevices = available + .map(({ platform }) => (platform === "ios" ? "iOS Simulators" : "Android emulators")) + .join(" and "); + const title = first + ? host.kind === "eas" + ? `${host.label} starts ${cloudDevices} in the cloud with EAS Simulator.` + : `${platformToolLabel(first.platform)} are available.` + : host.kind === "eas" + ? "EAS Simulator is unavailable on this host." + : "No simulator or emulator tools are available on this host."; + if (host.kind !== "eas" && ios?.available && android?.available) { + details.push(`${platformToolLabel("android")} are available too.`); + } + for (const platform of host.platforms) { + if (platform.available) continue; + details.push( + platform.reason ?? `${platform.platform === "ios" ? "iOS" : "Android"} is unavailable.`, + ); + } + if ( + host.kind !== "eas" && + !androidDiscoveryUncertain && + android?.available !== false && + !hasAndroidDevices + ) { + details.push( + "No Android virtual devices found. Create one in Android Studio's Device Manager, then refresh.", + ); + } + if (agentAccessEnabled && available.length > 0) { + details.push("Agents can boot a device when they need one."); + } + return { available: available.length > 0, title, details }; +} + +function groupDevices(state: DeviceServiceState, hostId: string | null) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) { const devices = state.devices - .filter((device) => device.platform === platform) + .filter( + (device) => device.platform === platform && (hostId === null || device.hostId === hostId), + ) .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name)); if (devices.length > 0) groups.push({ platform, devices }); } diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx index 3f8335150e6c2..3867fa0430198 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx @@ -150,7 +150,7 @@ describe("diff comment drafts", () => { await screen.rerender(view([refreshed], "loaded")); expect(editor().value).toBe("Keep this draft"); expect(container.querySelector("mark")?.textContent).toBe("Line 1"); - await click("Comment"); + await click("Done"); expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([ expect.objectContaining({ filePath: "shared.ts", diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 737897538eae8..992293e2cf423 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -28,6 +28,7 @@ interface DiffCommentAnnotationEntry { range: SelectedLineRange; rangeLabel: string; text: string; + createdAt?: string | undefined; } interface DiffCommentAnnotationGroup { @@ -99,6 +100,8 @@ interface AnnotatableCodeViewProps { fileKey: string, collapsed: boolean, ) => ReactNode; + /** Told each time a comment from this view lands in the composer draft. */ + onCommentAdded?: (commentId: string) => void; onFileCollapsedChange: (fileKey: string, collapsed: boolean) => void; } @@ -124,6 +127,7 @@ export function AnnotatableCodeView({ renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, + onCommentAdded, onFileCollapsedChange, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); @@ -176,6 +180,7 @@ export function AnnotatableCodeView({ range, rangeLabel: comment.rangeLabel, text: comment.text, + createdAt: comment.createdAt, }); }, []); const annotations = draft?.fileKey === fileKey ? [...persisted, draft.annotation] : persisted; @@ -221,7 +226,11 @@ export function AnnotatableCodeView({ range: entry.range, text, }); - if (comment) addReviewComment(composerDraftTarget, comment); + if (comment) { + // oxlint-disable-next-line react/purity -- Submission timestamps are read in this event handler (oxc#27309). + addReviewComment(composerDraftTarget, { ...comment, createdAt: new Date().toISOString() }); + onCommentAdded?.(comment.id); + } setSelectedLines(null); setDraft(null); setDraftText(""); @@ -292,9 +301,8 @@ export function AnnotatableCodeView({ : null } renderAnnotation={(...[annotation]) => { - const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft"); return ( -
+
{annotation.metadata.entries.map((entry) => ( removeEntry(entry.id)} diff --git a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx index 6cb2a6063c572..adbf5b4e5245c 100644 --- a/apps/web/src/components/diffs/DiffCommentAnnotation.tsx +++ b/apps/web/src/components/diffs/DiffCommentAnnotation.tsx @@ -1,7 +1,8 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { MessageCircle, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { PullRequestMarkdownTextarea, @@ -13,6 +14,8 @@ import { type TextFieldSpeech, } from "~/speech/useTextFieldSpeech"; import { textDirection } from "~/textDirection"; +import { useNowMinute } from "~/hooks/useNowMinute"; +import { formatElapsedDurationLabel } from "~/timestampFormat"; import { isCommentSubmitShortcut } from "./commentSubmitShortcut"; @@ -42,8 +45,62 @@ interface DiffCommentAnnotationProps { focusOnMount?: boolean; /** Set only for pull request comments, where `@` completes the repository's users. */ completionScope?: PullRequestCompletionScope | null; + /** When a saved comment was written, shown beside the author as a relative time. */ + createdAt?: string | undefined; + /** Where the comment lives now, such as "In composer", as a chip at the end of the head. */ + stateLabel?: string | undefined; } +/** Comments written here are the reader's own; there is no other author to name. */ +const LOCAL_AUTHOR = "You"; + +/** A relative time that moves on by itself, once a minute, instead of freezing at "just now". */ +function RelativeCommentTime({ createdAt }: { createdAt: string }) { + const now = useNowMinute(); + return {formatElapsedDurationLabel(createdAt, Date.parse(now))}; +} + +/** Avatar, author, then a range or time, and the comment's state at the far end. */ +export function CommentCardHead({ + meta, + stateLabel, + action, +}: { + meta?: ReactNode; + stateLabel?: ReactNode; + action?: ReactNode; +}) { + return ( +
+ + {LOCAL_AUTHOR} + {meta ? ( + {meta} + ) : null} + + {action} + {typeof stateLabel === "string" ? ( + {stateLabel} + ) : ( + stateLabel + )} + +
+ ); +} + +/** + * The raised card every inline comment shares: the popover fill and shadow, with a masked rim. + * Callers set `--surface-tint-rim` to pick the rim. + */ +export const COMMENT_CARD_CLASS = + "relative min-w-0 rounded-card bg-linear-to-b from-(--popover-top) to-(--popover-bottom) font-sans text-foreground shadow-(--popover-shadow) before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:surface-tint-rim"; + export function DiffCommentAnnotation({ environmentId, projectId, @@ -61,6 +118,8 @@ export function DiffCommentAnnotation({ secondaryAction, focusOnMount = true, completionScope = null, + createdAt, + stateLabel, }: DiffCommentAnnotationProps) { const [localDraftText, setLocalDraftText] = useState(""); const displayedText = kind === "draft" && !onTextChange ? localDraftText : text; @@ -80,29 +139,36 @@ export function DiffCommentAnnotation({ return (
event.stopPropagation()} > -
); } @@ -123,10 +189,11 @@ export function DiffCommentAnnotation({ const renderDraft = (speech?: TextFieldSpeech) => (
event.stopPropagation()} > + -
- ⌘/Ctrl Enter to send +
+ ⌘/Ctrl Enter to send {speech ? : null} + +
+
+
+ ); +} diff --git a/apps/web/src/components/diffs/DiffViewControls.tsx b/apps/web/src/components/diffs/DiffViewControls.tsx new file mode 100644 index 0000000000000..7888f7edc0063 --- /dev/null +++ b/apps/web/src/components/diffs/DiffViewControls.tsx @@ -0,0 +1,280 @@ +import { + Columns2Icon, + EllipsisIcon, + FlaskConicalOffIcon, + FolderTreeIcon, + PilcrowIcon, + TextWrapIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, +} from "lucide-react"; + +import { updateClientSettings } from "~/hooks/useSettings"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/menu"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { Separator } from "../ui/separator"; +import { Toggle } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** + * The view controls every diff surface shares: layout, wrap, whitespace, file tree and fold-all. + * Place it inside an `@container/diff-toolbar` row: narrow rows keep the layout toggle and move + * the rest into an overflow menu. + */ +export function DiffViewControls({ + diffLayout, + wordWrap, + onWordWrapChange, + ignoreWhitespace, + onIgnoreWhitespaceChange, + hasFiles, + fileTreeOpen, + onFileTreeOpenChange, + allFilesCollapsed, + onToggleAllFiles, + refresh, + testFileCount = 0, + hideTestFiles = false, + onHideTestFilesChange, +}: { + diffLayout: "stacked" | "split"; + wordWrap: boolean; + onWordWrapChange: (wordWrap: boolean) => void; + ignoreWhitespace: boolean; + onIgnoreWhitespaceChange: (ignoreWhitespace: boolean) => void; + /** Fold-all and the file tree only mean something once there are files. */ + hasFiles: boolean; + fileTreeOpen: boolean; + onFileTreeOpenChange: (open: boolean) => void; + allFilesCollapsed: boolean; + onToggleAllFiles: () => void; + refresh?: { refreshing: boolean; onRefresh: () => void } | undefined; + testFileCount?: number; + hideTestFiles?: boolean; + onHideTestFilesChange?: (hidden: boolean) => void; +}) { + const setDiffLayout = (value: unknown) => { + if (value === "stacked" || value === "split") { + updateClientSettings({ diffLayout: value }); + } + }; + + return ( + <> +
+ {refresh && ( + + + } + > + + + + {refresh.refreshing ? "Refreshing diff…" : "Refresh diff"} + + + )} + {hasFiles && ( + + + } + > + {allFilesCollapsed ? ( + + ) : ( + + )} + + + {allFilesCollapsed ? "Expand all files" : "Collapse all files"} + + + )} +
+
+ + setDiffLayout(pressed ? "split" : "stacked")} + /> + } + > + + + + {diffLayout === "split" ? "Stacked view" : "Split view"} + + +
+
+ + onWordWrapChange(Boolean(pressed))} + /> + } + > + + + + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + + + + onIgnoreWhitespaceChange(Boolean(pressed))} + /> + } + > + + + + {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + + + {testFileCount > 0 && onHideTestFilesChange && ( + + onHideTestFilesChange(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles + ? `Show ${testFileCount} hidden test ${testFileCount === 1 ? "file" : "files"}` + : "Hide test files"} + + + )} + {hasFiles && } + {hasFiles && ( + + onFileTreeOpenChange(Boolean(pressed))} + /> + } + > + + + + {fileTreeOpen ? "Hide file tree" : "Show file tree"} + + + )} +
+
+ + } + aria-label="More diff actions" + > + + + + + Stacked view + Split view + + + {refresh && ( + + Refresh diff + + )} + {hasFiles && ( + + {allFilesCollapsed ? "Expand all files" : "Collapse all files"} + + )} + onWordWrapChange(Boolean(checked))} + > + Wrap lines + + onIgnoreWhitespaceChange(Boolean(checked))} + > + Hide whitespace changes + + {testFileCount > 0 && onHideTestFilesChange && ( + onHideTestFilesChange(Boolean(checked))} + > + Hide test files + + )} + {hasFiles && ( + onFileTreeOpenChange(Boolean(checked))} + > + File tree + + )} + + +
+ + ); +} diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index 07700c4ba962e..6805aeadaa29c 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -266,23 +266,51 @@ export const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} align-items: center !important; line-height: 1 !important; font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +/* Pierre wraps each slotted header part in a block div that keeps min-width: auto, so a long name + would spill under the counts. The name gives way first; the suffix and the counts never shrink, + and the views fold their suffix away when the header is too narrow for it. */ +[data-header-content] ::slotted([slot="header-prefix"]) { + min-width: 0; +} + +[data-header-content] ::slotted([slot="header-filename-suffix"]) { + flex-shrink: 0; } +/* The header's counts and change glyph are drawn from the light DOM (DiffFileHeaderParts): + Pierre's own put deletions first, print raw numbers, and use its symbol set. */ [data-diffs-header] [data-additions-count], -[data-diffs-header] [data-deletions-count] { - font-family: var(--font-mono) !important; - font-weight: 400; - font-size: 11px !important; - font-variant-numeric: tabular-nums; - line-height: 1 !important; +[data-diffs-header] [data-deletions-count], +[data-diffs-header] [data-change-icon] { + display: none !important; +} + +/* The gutter's add-comment button: a small filled tile with a light glyph on every theme. */ +[data-utility-button] { + border-radius: 5px; + color: white; } -[data-diffs-header] [data-change-icon], [data-diffs-header] [data-rename-icon] { display: block; flex-shrink: 0; } +/* The header is the query container for the narrow title: under 32rem the full path gives way + to the slotted basename, which carries the matching @max-lg class. */ +[data-diffs-header] { + container-type: inline-size; +} + +@container (width < 32rem) { + [data-diffs-header] :is([data-title], [data-prev-name], [data-rename-icon]) { + display: none !important; + } +} + [data-title] { cursor: pointer; transition: color 120ms ease; @@ -316,6 +344,42 @@ export const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} } `; +/** + * Each file as its own rounded card, inset from the pane and spaced apart. The host element is + * the file, so the card is drawn on it. The rim is a masked overlay above the file's own + * backgrounds, so the card adds nothing to the item heights the viewer virtualizes against. + * The declarations are important because the page's reset (margin, outline) outranks `:host`. + */ +const FILE_CARDS_UNSAFE_CSS = ` +:host { + position: relative !important; + /* The scroller keeps a stable scrollbar gutter on the right; the card ends 16px from the + pane edge on both sides, the toolbar's trailing glyph line. */ + margin-inline: 16px calc(16px - var(--app-scrollbar-width)) !important; + border-radius: 12px !important; + overflow: clip !important; + box-shadow: var(--tooltip-shadow) !important; +} +:host::after { + content: ""; + position: absolute; + inset: 0; + z-index: 10; + pointer-events: none; + border-radius: inherit; + padding: 1px; + background: var(--popover-rim); + mask: + linear-gradient(#000 0 0) content-box exclude, + linear-gradient(#000 0 0); +} +/* The flush list's hover edge cue would be cut by the card's rounded corner. */ +[data-diffs-header]:hover { + box-shadow: none !important; +}`; + +const FILE_CARDS_GAP = 8; + export type StyledDiffCodeViewOptions = Omit< NonNullable["options"]>, "unsafeCSS" | "itemMetrics" | "layout" @@ -328,6 +392,8 @@ type StyledDiffCodeViewProps = ( readonly options?: StyledDiffCodeViewOptions; readonly viewerRef?: Ref>; readonly unsafeCSSExtra?: string; + /** "cards" draws each file as an inset card; the default runs files edge to edge. */ + readonly surface?: "flush" | "cards"; }; export function StyledDiffCodeView({ @@ -335,8 +401,13 @@ export function StyledDiffCodeView({ viewerRef, className, unsafeCSSExtra, + surface = "flush", ...props }: StyledDiffCodeViewProps) { + const cards = surface === "cards"; + const unsafeCSS = [DIFF_VIEW_UNSAFE_CSS, cards ? FILE_CARDS_UNSAFE_CSS : null, unsafeCSSExtra] + .filter(Boolean) + .join("\n"); return ( @@ -349,9 +420,7 @@ export function StyledDiffCodeView({ } options={{ ...options, - unsafeCSS: unsafeCSSExtra - ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}` - : DIFF_VIEW_UNSAFE_CSS, + unsafeCSS, itemMetrics: { diffHeaderHeight: 32, hunkSeparatorHeight: 24, @@ -363,7 +432,9 @@ export function StyledDiffCodeView({ // row. paddingBottom: 8, }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, + layout: cards + ? { paddingTop: FILE_CARDS_GAP, paddingBottom: FILE_CARDS_GAP, gap: FILE_CARDS_GAP } + : { paddingTop: 0, paddingBottom: 0, gap: 0 }, }} /> diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 8e59482a3a43b..636a6548f7d66 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -13,15 +13,14 @@ import { import { ChevronRightIcon, DownloadIcon, + FileQuestion, Trash2Icon, WrapTextIcon, XIcon, CheckIcon, - Code2Icon, CopyIcon, - EyeIcon, - Table2Icon, } from "lucide-react"; + import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; @@ -40,10 +39,12 @@ import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; import { FILE_SURFACE_SUBHEADER_CLASS, + FileRenderedViewToggle, FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading, FileSurfaceNotice, + FileSurfaceState, } from "./fileSurfaceChrome"; const LargeTextViewer = lazy(() => import("./LargeTextViewer")); @@ -81,16 +82,6 @@ export function ReadOnlySourcePreview(props: { name: string; text: string; wordW ); } -function renderedToggleLabel( - mode: "markdown" | "mermaid" | "html" | "table", - rendered: boolean, -): string { - if (mode === "mermaid") return rendered ? "Show Mermaid source" : "Show rendered diagram"; - if (mode === "markdown") return rendered ? "Show markdown source" : "Show rendered markdown"; - if (mode === "table") return rendered ? "Show source" : "Show table"; - return rendered ? "Show HTML source" : "Show rendered page"; -} - export function AttachmentFilePreview(props: { name: string; mimeType: string; @@ -377,13 +368,11 @@ export function AttachmentFilePreview(props: { onError={() => setError("Unable to load image.")} /> ) : ( -
-

No preview for this file

-

- Save it to open in an app that supports {props.name.split(".").at(-1) || "this format"}{" "} - files. -

-
+ } + title="No preview for this file" + description={`Save it to open in an app that supports ${props.name.split(".").at(-1) || "this format"} files.`} + /> ); return ( @@ -407,19 +396,11 @@ export function AttachmentFilePreview(props: { ) : null}
{renderedMode ? ( - setRendered((value) => !value)} - > - {rendered ? ( - - ) : renderedMode === "table" ? ( - - ) : ( - - )} - + ) : null} {showsRawText ? ( | null; + readonly sampleRate: number | null; + readonly channels: number | null; + readonly sizeBytes: number | null; +} + +/** Native sample rate from a RIFF/WAVE header; decoded buffers are resampled, so only the header knows it. */ +function wavSampleRate(bytes: ArrayBuffer): number | null { + if (bytes.byteLength < 28) return null; + const view = new DataView(bytes); + const riff = view.getUint32(0, false) === 0x52494646 && view.getUint32(8, false) === 0x57415645; + return riff ? view.getUint32(24, true) : null; +} + +function channelLabel(channels: number | null): string | null { + if (channels === null) return null; + if (channels === 1) return "mono"; + if (channels === 2) return "stereo"; + return `${channels} channels`; +} + +function sampleRateLabel(sampleRate: number | null): string | null { + if (sampleRate === null) return null; + const kiloHertz = sampleRate / 1000; + return `${Number.isInteger(kiloHertz) ? kiloHertz : kiloHertz.toFixed(1)} kHz`; +} + +// Audio decoding runs in the audio engine; the peak scan is bounded and never animates. +function useAudioSummary(src: string, duration: number): AudioSummary | null { + const [summary, setSummary] = useState<{ src: string; value: AudioSummary } | null>(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Fetch and decode waveform metadata for the current external media URL, aborting on cleanup. + useEffect(() => { + // Metadata arrives through the playback element. Never decode long or unknown recordings. + if (!Number.isFinite(duration) || duration <= 0 || duration > WAVEFORM_MAX_SECONDS) return; + const controller = new AbortController(); + void (async () => { + const response = await fetch(src, { signal: controller.signal }); + const declaredSize = Number(response.headers.get("content-length")); + const knownSize = Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : null; + if (!response.ok || knownSize === null || knownSize > WAVEFORM_MAX_BYTES) { + controller.abort(); + setSummary({ + src, + value: { peaks: null, sampleRate: null, channels: null, sizeBytes: knownSize }, + }); + return; + } + const bytes = await response.arrayBuffer(); + if (controller.signal.aborted) return; + if (bytes.byteLength > WAVEFORM_MAX_BYTES) return; + const sampleRate = wavSampleRate(bytes); + const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE); + const buffer = await context.decodeAudioData(bytes); + if (controller.signal.aborted) return; + if ( + buffer.duration > WAVEFORM_MAX_SECONDS || + buffer.length * buffer.numberOfChannels > WAVEFORM_MAX_SAMPLES + ) + return; + const samples = buffer.getChannelData(0); + const bucket = Math.max(1, Math.floor(samples.length / WAVEFORM_BARS)); + const stride = Math.max(1, Math.ceil(bucket / WAVEFORM_SAMPLES_PER_BAR)); + const peaks: number[] = []; + let loudest = 0; + for (let bar = 0; bar < WAVEFORM_BARS; bar++) { + let peak = 0; + const end = Math.min(samples.length, (bar + 1) * bucket); + for (let index = bar * bucket; index < end; index += stride) { + const value = Math.abs(samples[index] ?? 0); + if (value > peak) peak = value; + } + peaks.push(peak); + if (peak > loudest) loudest = peak; + } + if (controller.signal.aborted) return; + setSummary({ + src, + value: { + peaks: peaks.map((peak) => (loudest > 0 ? peak / loudest : 0)), + sampleRate, + channels: buffer.numberOfChannels, + sizeBytes: bytes.byteLength, + }, + }); + })().catch(() => { + if (controller.signal.aborted) return; + setSummary({ + src, + value: { peaks: null, sampleRate: null, channels: null, sizeBytes: null }, + }); + }); + return () => controller.abort(); + }, [src, duration]); + return summary?.src === src ? summary.value : null; +} + export const AudioPreviewUnmountContext = createContext<((audio: HTMLAudioElement) => void) | null>( null, ); @@ -33,10 +155,13 @@ export function AudioPreview(props: AudioPreviewProps) { function StandardAudioPreview(props: AudioPreviewProps) { const onUnmount = useContext(AudioPreviewUnmountContext); - const audioRef = useStableCallback((audio: HTMLAudioElement | null) => { - if (audio) return () => onUnmount?.(audio); + const [audio, setAudio] = useState(null); + const audioRef = useStableCallback((element: HTMLAudioElement | null) => { + setAudio(element); + if (element) return () => onUnmount?.(element); }); const [loadedSource, setLoadedSource] = useState<{ src: string; revision: string } | null>(null); + const [metadataSource, setMetadataSource] = useState(null); const [playing, setPlaying] = useState(false); const revision = props.revision ?? null; // Changed files load once playback pauses. @@ -44,16 +169,29 @@ function StandardAudioPreview(props: AudioPreviewProps) { setLoadedSource(revision === null ? null : { src: props.src, revision }); } const src = loadedSource?.src ?? props.src; + const { state, togglePlay, seek, toggleMuted } = useMediaPlayback(audio); + const summary = useAudioSummary(src, metadataSource === src ? state.duration : 0); + const progress = state.duration > 0 ? state.currentTime / state.duration : 0; + const peaks = summary?.peaks ?? null; + // Short clips need hundredths to show the playhead moving at all. + const precise = state.duration > 0 && state.duration < 10; + const metadata = mediaMetadataParts([ + mediaFormatLabel(props.name), + sampleRateLabel(summary?.sampleRate ?? null), + channelLabel(summary?.channels ?? null), + formatMediaSize(summary?.sizeBytes ?? null), + ]); + return ( -
+
); } diff --git a/apps/web/src/components/files/FileMediaDetails.tsx b/apps/web/src/components/files/FileMediaDetails.tsx new file mode 100644 index 0000000000000..cb65ff579d536 --- /dev/null +++ b/apps/web/src/components/files/FileMediaDetails.tsx @@ -0,0 +1,83 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { FolderOpenIcon, MessageSquarePlusIcon } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useComposerHandleContext } from "~/composerHandleContext"; +import { useFileContextMenu } from "~/fileContextMenu"; +import { usePrimaryEnvironmentId } from "~/state/environments"; +import { isAbsolutePath } from "@t3tools/shared/path"; + +import { addFileMentionToChat } from "./addFileToChat"; + +/** + * The row under a media preview: the file's metadata line, then Add to chat + * and Reveal. Reveal opens the host's file manager, so it only appears for the + * primary (local) environment and when its server can open a file manager. + */ +export function FileMediaDetails(props: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly relativePath: string; + readonly metadata: ReadonlyArray; +}) { + const composerRef = useComposerHandleContext(); + const fileContextMenu = useFileContextMenu(props.environmentId); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const revealLabel = + props.environmentId === primaryEnvironmentId + ? fileContextMenu.capabilities.revealLabel + : undefined; + const canAddToChat = composerRef !== null && !isAbsolutePath(props.relativePath); + return ( +
+ + + }>{props.metadata[0]} + + {props.metadata.slice(1).map((part) => ( + + {part} + + ))} + + + + {canAddToChat ? ( + + ) : null} + {revealLabel !== undefined ? ( + + + void fileContextMenu.activate("reveal-in-folder", { + environmentId: props.environmentId, + filePath: props.relativePath, + workspaceRoot: props.cwd, + }) + } + /> + } + > + + Reveal + + {revealLabel} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index b36940287c2c7..ce6321ca91b51 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -94,6 +94,7 @@ vi.mock(import("./projectFilesQueryState"), async (original) => ({ readError: null, isNotFile: false, isFile: true, + isBinary: false, refresh: vi.fn(), }), getOptimisticProjectFileQueryData: () => state.optimistic, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 5034a0228b5f4..b3b715b5be7e8 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -45,7 +45,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { FolderTree, Globe2, WrapTextIcon } from "lucide-react"; +import { EllipsisIcon, FolderTree, Globe2, MessageSquarePlusIcon } from "lucide-react"; import * as Schema from "effect/Schema"; import { lazy, @@ -61,6 +61,13 @@ 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 { + formatMediaLength, + formatMediaSize, + mediaFormatLabel, + mediaMetadataParts, + useMediaByteSize, +} from "~/components/media/mediaFileInfo"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; @@ -73,7 +80,11 @@ import { requestWorkspaceFocus } from "~/lib/workspaceFocus"; import type { ChatFileAttachment } from "~/types"; import { isHostPath } from "@t3tools/shared/path"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { Button } from "~/components/ui/button"; +import { Menu, MenuCheckboxItem, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useComposerHandleContext } from "~/composerHandleContext"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment, type ReviewCommentContext } from "~/reviewCommentContext"; @@ -87,6 +98,7 @@ import { useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; +import { addFileMentionToChat } from "./addFileToChat"; import { AttachmentFilePreview } from "./AttachmentFilePreview"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; @@ -94,6 +106,7 @@ import { DelimitedTablePreview } from "./DelimitedTablePreview"; import { PreviewImage } from "./PreviewImage"; import FileBrowserPanel from "./FileBrowserPanel"; import { FileExplorerPane } from "./FileExplorerPane"; +import { FileMediaDetails } from "./FileMediaDetails"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; @@ -112,6 +125,7 @@ import { FILE_LINK_REVEAL_ATTRIBUTE, FILE_LINK_REVEAL_UNSAFE_CSS, FILE_SURFACE_SUBHEADER_CLASS, + FileRenderedViewToggle, FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading, @@ -213,7 +227,7 @@ function WorkspaceImagePreview(props: { if (assetUrl._tag === "Failure") { return ( -
+
Unable to load workspace image.
@@ -271,7 +285,7 @@ export function WorkspaceBrowserPreview(props: { if (assetUrl._tag === "Failure") { return ( -
+
Unable to load file preview.
); @@ -330,16 +344,32 @@ function WorkspaceVideoPreview(props: { ? "" : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}${fileRevision !== undefined ? "file" : "workspace"}-revision=${encodeURIComponent(revision)}`; const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + const [videoMetadata, setVideoMetadata] = useState<{ + width: number; + height: number; + duration: number; + } | null>(null); + const sizeBytes = useMediaByteSize(latestUrl); + const metadata = mediaMetadataParts([ + mediaFormatLabel(props.name), + videoMetadata && videoMetadata.width > 0 + ? `${videoMetadata.width}×${videoMetadata.height}` + : null, + videoMetadata ? formatMediaLength(videoMetadata.duration) : null, + formatMediaSize(sizeBytes), + ]); return ( -
+
+
+ +
); } @@ -818,9 +856,8 @@ function EditableFileSurface({ for (const annotation of remapped) { for (const entry of annotation.metadata.entries) { if (entry.kind !== "comment" || !promptCommentIds.has(entry.id)) continue; - addReviewComment( - composerDraftTarget, - buildFileReviewComment({ + addReviewComment(composerDraftTarget, { + ...buildFileReviewComment({ id: entry.id, filePath: relativePath, startLine: entry.startLine, @@ -828,7 +865,8 @@ function EditableFileSurface({ text: entry.text, contents: file.contents, }), - ); + ...(entry.createdAt !== undefined ? { createdAt: entry.createdAt } : {}), + }); } } }; @@ -849,10 +887,11 @@ function EditableFileSurface({ const entry = lineAnnotations .flatMap((annotation) => annotation.metadata.entries) .find((candidate) => candidate.id === entryId); + // oxlint-disable-next-line react/purity -- Submission timestamps are read in this event handler (oxc#27309). + const createdAt = new Date().toISOString(); if (entry) { - addReviewComment( - composerDraftTarget, - buildFileReviewComment({ + addReviewComment(composerDraftTarget, { + ...buildFileReviewComment({ id: entry.id, filePath: relativePath, startLine: entry.startLine, @@ -860,7 +899,8 @@ function EditableFileSurface({ text, contents, }), - ); + createdAt, + }); } setLineAnnotations((current) => current.map((annotation) => ({ @@ -868,7 +908,7 @@ function EditableFileSurface({ metadata: { entries: annotation.metadata.entries.map((annotationEntry) => annotationEntry.id === entryId - ? { ...annotationEntry, kind: "comment", text } + ? { ...annotationEntry, kind: "comment", text, createdAt } : annotationEntry, ), }, @@ -991,7 +1031,7 @@ function EditableFileSurface({ ); if (entries.length === 0) return null; return ( -
+
{entries.map((entry) => ( removeAnnotationEntry(entry.id)} onComment={(text) => submitAnnotationEntry(entry.id, text)} @@ -1093,16 +1137,6 @@ function RenderedMarkdownSurface({ ); } -function renderedToggleLabel( - mode: "markdown" | "mermaid" | "html" | "table", - rendered: boolean, -): string { - if (mode === "mermaid") return rendered ? "Show Mermaid source" : "Show rendered diagram"; - if (mode === "markdown") return rendered ? "Show markdown source" : "Show rendered markdown"; - if (mode === "table") return rendered ? "Show source" : "Show table"; - return rendered ? "Show HTML source" : "Show rendered page"; -} - function initialExplorerOpen(): boolean { try { return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; @@ -1137,6 +1171,7 @@ export default function FilePreviewPanel({ attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const draft = typeof composerDraftTarget === "string"; const { resolvedTheme } = useTheme(); + const composerRef = useComposerHandleContext(); const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); const [wordWrapOverride, setWordWrapOverride] = useState(null); const wordWrap = wordWrapOverride ?? defaultWordWrap; @@ -1342,6 +1377,9 @@ export default function FilePreviewPanel({ : relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; + // Mentions resolve against the workspace, so only workspace files can be added. + const canAddToChat = + previewPath !== null && relativePath !== null && !isHostFile && composerRef !== null; const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); // Mounting the file read under the workspace mutation id refetches it after each mutation. useAtomMount( @@ -1434,10 +1472,11 @@ export default function FilePreviewPanel({ className="min-w-0 flex-1" data-file-breadcrumbs > -
+
+ {canToggleRendered && renderedMode ? ( + { + if (pressed === rendered) return; + setRenderedPreferred(pressed); + setHandledReveal( + pressed && relativePath !== null + ? { path: relativePath, requestId: revealRequestId } + : null, + ); + }} + /> + ) : null} + {canAddToChat ? ( + { + if (relativePath) addFileMentionToChat(composerRef, relativePath); + }} + > + + + ) : null} {absolutePath && (environmentId === primaryEnvironmentId || environmentId === hostEnvironmentId || @@ -1462,42 +1526,42 @@ export default function FilePreviewPanel({ revealFileManager={revealsInFileManager} /> ) : null} - {canToggleRendered && renderedMode ? ( - { - const pressed = !rendered; - setRenderedPreferred(pressed); - setHandledReveal( - pressed && relativePath !== null - ? { path: relativePath, requestId: revealRequestId } - : null, - ); - }} - > - {rendered ? ( - - ) : renderedMode === "table" ? ( - - ) : ( - - )} - - ) : null} - {showsRawText ? ( - setWordWrapOverride(!wordWrap)} - > - - - ) : null} - {canOpenInBrowser ? ( - - - + {showsRawText || canOpenInBrowser ? ( + + + + } + /> + } + > + + + More file actions + + + {showsRawText ? ( + + Word wrap + + ) : null} + {canOpenInBrowser ? ( + + + Open file in preview browser + + ) : null} + + ) : null} {!isHostFile && previewPath !== null ? ( ) : relativePath && file.error && file.data === null ? ( -
-
-

+ {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error} -

- {attemptedPath ? ( -

- Attempted path - - {attemptedPath} - -

- ) : null} - {!isHostFile ? ( -

- Workspace folder:{" "} - - {file.readError?.cwd ?? cwd} - - . Check the link's path or locate the file in Files. -

- ) : null} -
-
+ {attemptedPath ? ( + + Attempted path + + {attemptedPath} + + + ) : null} + {!isHostFile ? ( + + Workspace folder:{" "} + + {file.readError?.cwd ?? cwd} + + . Check the link's path or locate the file in Files. + + ) : null} + + } + onRetry={file.refresh} + /> ) : relativePath && file.data === null ? ( -
- -
+ ) : relativePath && file.data ? ( renderMermaid ? ( }> diff --git a/apps/web/src/components/files/LargeTextViewer.tsx b/apps/web/src/components/files/LargeTextViewer.tsx index bff552a8cbdd6..f38a606657bff 100644 --- a/apps/web/src/components/files/LargeTextViewer.tsx +++ b/apps/web/src/components/files/LargeTextViewer.tsx @@ -1,5 +1,5 @@ import { useEffect, useImperativeHandle, useRef, useState, type RefObject } from "react"; -import { parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; +import { delimitedColumnKinds, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { useClientSettings } from "~/hooks/useSettings"; import { FileSurfaceFailure, FileSurfaceLoading, FileSurfaceNotice } from "./fileSurfaceChrome"; @@ -215,10 +215,17 @@ export default function LargeTextViewer(props: { ); if (!meta || (meta.count > 0 && !pages.has(0))) return ; const header = props.delimiter ? pages.get(0)?.[0] : undefined; + const columnKinds = props.delimiter + ? delimitedColumnKinds(pages.get(0)?.slice(1) ?? [], meta.columns) + : undefined; return (
@@ -232,6 +239,7 @@ export default function LargeTextViewer(props: { {props.delimiter ? ( <> {countLabel(meta.count ? meta.columns : 0, "column")} + {props.delimiter === "," ? "Comma" : "Tab"} separated ) : null} @@ -260,6 +268,7 @@ export default function LargeTextViewer(props: { count={meta.count} columns={meta.columns} header={header} + columnKinds={columnKinds} source={!props.delimiter} wrap={wordWrap} revealLine={meta.complete ? (props.revealLine ?? null) : null} diff --git a/apps/web/src/components/files/VirtualTablePreview.tsx b/apps/web/src/components/files/VirtualTablePreview.tsx index c04ec238752f8..71495499062bf 100644 --- a/apps/web/src/components/files/VirtualTablePreview.tsx +++ b/apps/web/src/components/files/VirtualTablePreview.tsx @@ -1,9 +1,15 @@ /* oxlint-disable react/no-array-index-key -- Positions identify source rows and columns. */ import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; +import type { DelimitedColumnKind } from "@t3tools/shared/delimitedPreview"; import { observeResize } from "~/lib/observeResize"; +import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { PREVIEW_PAGE_ROWS } from "./largeTextProtocol"; +function columnClass(kind: DelimitedColumnKind | undefined) { + return kind === "number" ? "text-right font-mono" : kind === "date" ? "font-mono" : "text-left"; +} + export default function VirtualTablePreview(props: { readonly name: string; readonly count: number; @@ -11,6 +17,7 @@ export default function VirtualTablePreview(props: { readonly getRows: (page: number) => readonly (readonly string[])[] | undefined; readonly loadPage?: (page: number) => void; readonly header?: readonly string[] | undefined; + readonly columnKinds?: readonly DelimitedColumnKind[] | undefined; readonly source?: boolean; readonly wrap?: boolean; readonly revealLine?: number | null; @@ -47,10 +54,10 @@ export default function VirtualTablePreview(props: { ); if (widths.length) setMeasured(widths); }, [measured, width, props.header]); + const rowHeader = props.source ? 56 : 28; const columnWidth = props.source ? 0 - : Math.max(128, Math.min(320, (width - 56) / Math.min(props.columns, 6))); - const rowHeader = 56; + : Math.max(128, Math.min(320, (width - rowHeader) / Math.min(props.columns, 6))); const sizes = Array.from( { length: props.columns }, (_, column) => measured?.[column] ?? columnWidth, @@ -75,7 +82,10 @@ export default function VirtualTablePreview(props: { return (
{ event.currentTarget.style.setProperty( "--preview-scroll-left", @@ -96,7 +106,10 @@ export default function VirtualTablePreview(props: { {props.header.map((cell, i) => ( {cell} @@ -112,7 +125,10 @@ export default function VirtualTablePreview(props: { {row.map((cell, j) => ( {cell} @@ -134,9 +150,12 @@ export default function VirtualTablePreview(props: { {!props.source ? (
-
+
{columns.map((column) => ( @@ -145,7 +164,10 @@ export default function VirtualTablePreview(props: {
{props.header?.[column] ?? columnLabel(column)} @@ -206,7 +228,7 @@ function RowPage( className={ props.source ? `flex min-h-[29px] font-mono ${props.revealLine != null && Math.min(props.count, Math.max(1, props.revealLine)) === index + 1 ? "bg-primary/10" : ""}` - : `flex min-h-[29px] border-b border-border ${(index - (props.header ? 1 : 0)) % 2 ? "surface-card" : ""}` + : "flex min-h-[29px] border-b border-divider" } data-preview-reveal={ props.revealLine != null && @@ -217,7 +239,12 @@ function RowPage( >
diff --git a/apps/web/src/components/files/addFileToChat.ts b/apps/web/src/components/files/addFileToChat.ts new file mode 100644 index 0000000000000..3d981a67cf4cd --- /dev/null +++ b/apps/web/src/components/files/addFileToChat.ts @@ -0,0 +1,33 @@ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; + +import { toastManager } from "~/components/ui/toast"; +import type { ComposerHandleRef } from "~/composerHandleContext"; + +/** + * Appends a file mention to the open chat composer, the "Add to chat" action + * shared by the files tree menu, the file preview header and media previews. + */ +export function addFileMentionToChat( + composerRef: ComposerHandleRef | null, + relativePath: string, +): void { + const composer = composerRef?.current; + if (!composer) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "Open a chat for this project and try again.", + }); + return; + } + const inserted = composer.insertTextAtEnd(`${serializeComposerFileLink(relativePath)} `, { + ensureLeadingBoundary: true, + }); + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The chat isn't ready to accept input right now.", + }); + } +} diff --git a/apps/web/src/components/files/fileCommentAnnotations.ts b/apps/web/src/components/files/fileCommentAnnotations.ts index 2ee2ccb0c0f8b..3c4f4b7ae6cd0 100644 --- a/apps/web/src/components/files/fileCommentAnnotations.ts +++ b/apps/web/src/components/files/fileCommentAnnotations.ts @@ -6,6 +6,8 @@ export interface FileCommentAnnotationEntry { startLine: number; endLine: number; text: string; + /** ISO time the comment was saved, for its card's relative time. */ + createdAt?: string | undefined; } export interface FileCommentAnnotationGroup { diff --git a/apps/web/src/components/files/fileSurfaceChrome.tsx b/apps/web/src/components/files/fileSurfaceChrome.tsx index c7037e9049f93..f311571b50399 100644 --- a/apps/web/src/components/files/fileSurfaceChrome.tsx +++ b/apps/web/src/components/files/fileSurfaceChrome.tsx @@ -1,14 +1,25 @@ +import { FileWarning, RotateCw } from "lucide-react"; import type { ReactNode } from "react"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "~/components/ui/empty"; + import { Spinner } from "~/components/ui/spinner"; import { Button } from "~/components/ui/button"; import { Toggle } from "~/components/ui/toggle"; +import { Toggle as ToggleGroupItem, ToggleGroup } from "~/components/ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; export const FILE_SURFACE_SUBHEADER_CLASS = - "flex h-10 min-h-10 shrink-0 items-center gap-2 border-b border-divider bg-background px-3 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-divider"; + "flex h-10 min-h-10 shrink-0 items-center gap-2 border-b border-divider bg-background ps-4 pe-3 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-divider"; export const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; @@ -106,6 +117,60 @@ export function FileSurfaceAction(props: { ); } +export type FileRenderedMode = "markdown" | "mermaid" | "html" | "table"; + +/** + * The Preview/Source switch for files that have a rendered view. Workspace + * files and attachments share it so both headers read the same. + */ +export function FileRenderedViewToggle(props: { + readonly mode: FileRenderedMode; + readonly rendered: boolean; + readonly onRenderedChange: (rendered: boolean) => void; +}) { + const { mode } = props; + return ( + { + const next = value[0]; + if (next === "rendered" || next === "source") props.onRenderedChange(next === "rendered"); + }} + > + + {mode === "table" ? "Table" : "Preview"} + + + Source + + + ); +} + export function FileSurfaceNotice(props: { readonly children: ReactNode }) { return (
+ + {props.icon} + {props.title} + {props.description ? ( + {props.description} + ) : null} + + {props.action ? {props.action} : null} + + ); +} + export function FileSurfaceFailure(props: { - readonly message: string; + readonly message: ReactNode; + readonly title?: string; readonly onRetry?: () => void; }) { return ( -
-

{props.message}

- {props.onRetry ? ( - - ) : null} -
+ icon={} + title={props.title ?? "Can't open this file"} + description={props.message} + action={ + props.onRetry ? ( + + ) : undefined + } + /> ); } diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 8b991d069d225..973e1084e8330 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -52,6 +52,8 @@ interface ProjectFileQueryState extends ProjectQueryState readonly readError: ProjectReadFileError | null; readonly isNotFile: boolean; readonly isFile: boolean; + /** The file exists but holds binary data, so it has no text preview. */ + readonly isBinary: boolean; } function getProjectEntriesQueryAtom( @@ -280,6 +282,7 @@ export function useProjectFileQuery( isNotFile, // A folder error overrides cached contents retained across failed refreshes. isFile: !isNotFile && (data !== null || readError?.failure === "binary_file"), + isBinary: readError?.failure === "binary_file", refresh, }; } diff --git a/apps/web/src/components/media/MediaTransport.tsx b/apps/web/src/components/media/MediaTransport.tsx new file mode 100644 index 0000000000000..ce9a4053226c8 --- /dev/null +++ b/apps/web/src/components/media/MediaTransport.tsx @@ -0,0 +1,225 @@ +import { + MaximizeIcon, + MinimizeIcon, + PauseIcon, + PlayIcon, + Volume2Icon, + VolumeXIcon, +} from "lucide-react"; +import { useEffect, useState, useSyncExternalStore } from "react"; + +import { cn } from "~/lib/utils"; +import { ContentButton } from "../ui/button"; + +import { formatPlaybackTime } from "./mediaFileInfo"; + +export interface MediaPlaybackState { + readonly paused: boolean; + readonly currentTime: number; + readonly duration: number; + readonly muted: boolean; +} + +const IDLE_PLAYBACK: MediaPlaybackState = { + paused: true, + currentTime: 0, + duration: 0, + muted: false, +}; + +function createMediaPlayback(element: HTMLMediaElement | null) { + let snapshot = IDLE_PLAYBACK; + const getSnapshot = () => { + if (element === null) return IDLE_PLAYBACK; + const duration = Number.isFinite(element.duration) ? element.duration : 0; + if ( + snapshot.paused !== element.paused || + snapshot.currentTime !== element.currentTime || + snapshot.duration !== duration || + snapshot.muted !== element.muted + ) { + snapshot = { + paused: element.paused, + currentTime: element.currentTime, + duration, + muted: element.muted, + }; + } + return snapshot; + }; + const subscribe = (onChange: () => void) => { + if (element === null) return () => {}; + const events = [ + "play", + "pause", + "ended", + "timeupdate", + "seeked", + "durationchange", + "loadedmetadata", + "volumechange", + "emptied", + ] as const; + for (const event of events) element.addEventListener(event, onChange); + return () => { + for (const event of events) element.removeEventListener(event, onChange); + }; + }; + + const togglePlay = () => { + if (element === null) return; + if (element.paused || element.ended) void element.play().catch(() => undefined); + else element.pause(); + }; + const seek = (seconds: number) => { + if (element === null || !Number.isFinite(seconds)) return; + element.currentTime = Math.max(0, Math.min(seconds, element.duration || seconds)); + }; + const toggleMuted = () => { + if (element !== null) element.muted = !element.muted; + }; + return { getSnapshot, subscribe, togglePlay, seek, toggleMuted }; +} + +/** + * Mirrors a media element's playback into React for custom controls. It only + * listens to the element's own events (timeupdate fires a few times a second + * while playing), so a paused player never re-renders. + */ +export function useMediaPlayback(element: HTMLMediaElement | null) { + const playback = createMediaPlayback(element); + const state = useSyncExternalStore(playback.subscribe, playback.getSnapshot, () => IDLE_PLAYBACK); + return { + state, + togglePlay: playback.togglePlay, + seek: playback.seek, + toggleMuted: playback.toggleMuted, + }; +} + +/** + * A thin progress track with an invisible native range on top, so the look is + * the redesign's while keyboard and screen reader seeking stay native. + */ +export function MediaScrubber(props: { + readonly state: MediaPlaybackState; + readonly onSeek: (seconds: number) => void; + readonly label: string; + readonly trackClassName: string; + readonly playedClassName: string; +}) { + const { duration, currentTime } = props.state; + const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0; + return ( + + + + + 0 ? duration : 0} + step="any" + value={duration > 0 ? currentTime : 0} + disabled={duration <= 0} + onChange={(event) => props.onSeek(Number(event.currentTarget.value))} + className="absolute inset-0 m-0 w-full cursor-pointer opacity-0 disabled:cursor-default" + /> + + ); +} + +/** + * The redesign's video transport: play, time, scrubber, mute and fullscreen, + * laid over the bottom of the frame. Fullscreen targets the whole player so + * these controls come along instead of the browser's. + */ +export function VideoTransport(props: { + readonly video: HTMLVideoElement | null; + readonly fullscreenTarget: HTMLElement | null; +}) { + const { state, togglePlay, seek, toggleMuted } = useMediaPlayback(props.video); + const [fullscreen, setFullscreen] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Subscribe to the browser's fullscreen state for this player and remove the listener on cleanup. + useEffect(() => { + const target = props.fullscreenTarget; + if (target === null) return; + const sync = () => setFullscreen(document.fullscreenElement === target); + sync(); + document.addEventListener("fullscreenchange", sync); + return () => document.removeEventListener("fullscreenchange", sync); + }, [props.fullscreenTarget]); + const toggleFullscreen = () => { + const target = props.fullscreenTarget; + if (target === null) return; + if (document.fullscreenElement === target) + void document.exitFullscreen().catch(() => undefined); + else void target.requestFullscreen?.().catch(() => undefined); + }; + const canFullscreen = + typeof document !== "undefined" && + document.fullscreenEnabled && + props.fullscreenTarget !== null; + + return ( + + + {state.paused ? ( + + ) : ( + + )} + + + {formatPlaybackTime(state.currentTime)} / {formatPlaybackTime(state.duration)} + + + + {state.muted ? ( + + ) : ( + + )} + + {canFullscreen ? ( + + {fullscreen ? ( + + ) : ( + + )} + + ) : null} + + ); +} diff --git a/apps/web/src/components/media/MediaVideoPlayer.browser.test.tsx b/apps/web/src/components/media/MediaVideoPlayer.browser.test.tsx index e66d26b5349e5..8fca119ede9b4 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.browser.test.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.browser.test.tsx @@ -68,26 +68,46 @@ describe("MediaVideoPlayer", () => { await expectVideoSrc(screen, "/asset/first/clip.mp4"); }); - it("starts loading metadata once the player scrolls near the viewport", async () => { - using _observer = stubGlobal("IntersectionObserver", VisibleIntersectionObserver); - await using screen = await render(); - - await expect.element(screen.getByLabelText("clip")).toHaveAttribute("preload", "metadata"); - }); - - it("keeps a playing video playing when the player rerenders", async () => { - using _observer = stubGlobal("IntersectionObserver", HiddenIntersectionObserver); - await using screen = await render( - , - ); - const video = screen.getByLabelText("clip").element() as HTMLVideoElement; - using pause = vi.spyOn(video, "pause"); - await act(() => { - video.dispatchEvent(new Event("play")); - }); - - await screen.rerender(); - - expect(pause).not.toHaveBeenCalled(); - }); + it.each(["native", "custom"] as const)( + "starts loading metadata near the viewport with %s controls", + async (transport) => { + using _observer = stubGlobal("IntersectionObserver", VisibleIntersectionObserver); + await using screen = await render( + , + ); + + await expect.element(screen.getByLabelText("clip")).toHaveAttribute("preload", "metadata"); + }, + ); + + it.each(["native", "custom"] as const)( + "keeps a playing video playing when the player rerenders with %s controls", + async (transport) => { + using _observer = stubGlobal("IntersectionObserver", HiddenIntersectionObserver); + await using screen = await render( + , + ); + const video = screen.getByLabelText("clip").element() as HTMLVideoElement; + using pause = vi.spyOn(video, "pause"); + await act(() => { + video.dispatchEvent(new Event("play")); + }); + + await screen.rerender( + , + ); + + expect(pause).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index cfb4f349f214d..cd117235a2ad6 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -8,6 +8,7 @@ import { cn } from "../../lib/utils"; import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; import { Button } from "../ui/button"; import { OpenMediaLink } from "./OpenMediaLink"; +import { VideoTransport } from "./MediaTransport"; import { MediaActions, type MediaActionSource } from "./MediaActions"; interface MediaVideoPlayerProps { @@ -27,6 +28,12 @@ interface MediaVideoPlayerProps { readonly copyMarkdown?: string | undefined; readonly onRetry?: (() => Promise) | undefined; readonly actionsSource?: MediaActionSource | undefined; + /** "custom" swaps the browser's controls for the redesign's transport bar. */ + readonly transport?: "native" | "custom" | undefined; + /** Reports the intrinsic size and length once metadata loads. */ + readonly onMetadata?: + | ((metadata: { width: number; height: number; duration: number }) => void) + | undefined; } export function MediaVideoPlayer({ @@ -46,7 +53,12 @@ export function MediaVideoPlayer({ copyMarkdown, onRetry, actionsSource, + transport = "native", + onMetadata, }: MediaVideoPlayerProps) { + const [video, setVideo] = useState(null); + const [playerElement, setPlayerElement] = useState(null); + const customTransport = transport === "custom" && !onOpen; const [playbackSource, setPlaybackSource] = useState<{ src: string; revision: string | null; @@ -75,6 +87,7 @@ export function MediaVideoPlayer({ // Retry and failure remount the element, so its listeners live and die with it. const attachVideo = (video: HTMLVideoElement) => { + setVideo(video); const pauseWhenHidden = () => { // Native fullscreen can hide the inline page while this video is still visible. const fullscreen = @@ -90,6 +103,7 @@ export function MediaVideoPlayer({ document.removeEventListener("fullscreenchange", pauseWhenHidden); video.removeEventListener("webkitendfullscreen", pauseWhenHidden); video.pause(); + setVideo(null); // A detached element's pause event never reaches onPause. setPlaying(false); }; @@ -97,6 +111,7 @@ export function MediaVideoPlayer({ // On the wrapper, not the video: re-running the video's ref callback would pause playback. const observePreload = (player: HTMLSpanElement) => { + setPlayerElement(player); if (src === null || failed || preload === "metadata" || preloaded) return; return observeIntersection( player, @@ -126,7 +141,12 @@ export function MediaVideoPlayer({ const player = ( @@ -167,7 +187,7 @@ export function MediaVideoPlayer({ aria-label={label || "Video preview"} aria-hidden={onOpen ? true : undefined} autoPlay={onOpen ? false : autoPlay} - controls={!onOpen} + controls={!onOpen && !customTransport} muted={onOpen ? true : undefined} playsInline preload={preload === "metadata" || preloaded ? "metadata" : "none"} @@ -178,9 +198,24 @@ export function MediaVideoPlayer({ )} style={style} onLoadedMetadata={(event) => { - prepareVideoFirstFrame(event.currentTarget); + const video = event.currentTarget; + prepareVideoFirstFrame(video); if (sourceRevision !== null) setPlaybackSource({ src, revision: sourceRevision }); + onMetadata?.({ + width: video.videoWidth, + height: video.videoHeight, + duration: video.duration, + }); }} + onClick={ + customTransport + ? (event) => { + const video = event.currentTarget; + if (video.paused || video.ended) void video.play().catch(() => undefined); + else video.pause(); + } + : undefined + } onPlay={() => { setPlaying(true); setPlaybackSource({ src, revision: sourceRevision }); @@ -201,6 +236,9 @@ export function MediaVideoPlayer({ style={style} /> )} + {customTransport && src !== null && !failed ? ( + + ) : null} {onOpen ? (
); } diff --git a/apps/web/src/components/preview/BrowserMockup.tsx b/apps/web/src/components/preview/BrowserMockup.tsx deleted file mode 100644 index 6332de2fb597a..0000000000000 --- a/apps/web/src/components/preview/BrowserMockup.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { cn } from "~/lib/utils"; - -export function BrowserMockup({ className }: { className?: string }) { - return ( -
-
- - - -
-
- - -
-
- ); -} diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 5dbd3916ae3b1..1211c27fa3e69 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,31 +1,35 @@ -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { parseUrl } from "@t3tools/shared/url"; -import { Globe, History, RadioTower } from "lucide-react"; +import { Globe, Link } from "lucide-react"; import type { BrowserHistoryEntry } from "~/browserHistoryStore"; -import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; -import { DiscoveryList } from "../ui/discovery-list"; +import { Button } from "~/components/ui/button"; import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; interface Props { - threadRef: ScopedThreadRef; environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; recentEntries: ReadonlyArray; onRemoveRecent: (url: string) => void; onOpenUrl: (url: string) => void; + /** Focuses the chrome row's address field. */ + onFocusUrl?: () => void; } +/** + * The browser's blank tab: a hero that points at the address field, then live + * local servers, then recently visited pages. + */ export function PreviewEmptyState({ - threadRef, environmentId, configuredUrls, recentEntries, onRemoveRecent, onOpenUrl, + onFocusUrl, }: Props) { const servers = useDiscoveredLocalServers({ environmentId, @@ -33,63 +37,52 @@ export function PreviewEmptyState({ }); const recents = recentEntries.filter((entry) => parseUrl(entry.url) !== null).slice(0, 8); - if (servers.length === 0 && recents.length === 0) { - return ( - - - - - No preview yet - - Type a URL above, or run a dev script. Local servers this thread starts will show up here - automatically. - - - ); - } - return ( -
-
- {recents.length > 0 ? ( -
-
- -

Recently used

+
+
+
+ + + +

No preview yet

+

+ Start a dev server or open a URL. Agents can open pages here too. +

+ {onFocusUrl ? ( +
+
- + ) : null} +
+ {servers.length > 0 ? ( +
+

Local servers

+ {servers.map((server) => ( + onOpenUrl(server.requestedUrl)} + /> + ))} +
+ ) : null} + {recents.length > 0 ? ( +
+

Recently used

+
{recents.map((entry) => ( onOpenUrl(entry.url)} onRemove={() => onRemoveRecent(entry.url)} /> ))} - -
- ) : null} - {servers.length > 0 ? ( -
-
- -

Local servers

- - {servers.map((server) => ( - onOpenUrl(server.requestedUrl)} - /> - ))} - -

- Select a live local server to open it in this browser tab. -

-
+
) : null}
diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx index b2e1fee3639ed..b104e16b85a0f 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx @@ -1,11 +1,5 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; import { type ReactNode, useState } from "react"; -import { useFaviconForThreadUrl } from "~/browserFaviconStore"; -import { cn } from "~/lib/utils"; - -import { BrowserMockup } from "./BrowserMockup"; - export function FaviconImage(props: { sources: ReadonlyArray; fallback: ReactNode; @@ -41,19 +35,3 @@ function FaviconImageAttempt(props: { /> ); } - -export function PreviewFaviconIcon(props: { - threadRef: ScopedThreadRef; - url: string; - className?: string | undefined; -}) { - const source = useFaviconForThreadUrl(props.threadRef, props.url); - const fallback = ; - return ( - - ); -} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index 198d0495473ca..a06db8dad4a37 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,24 +1,35 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; -import { DiscoveryListRow } from "../ui/discovery-list"; +import { ArrowUpRight } from "lucide-react"; + +import { Button } from "~/components/ui/button"; -import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; interface Props { - threadRef: ScopedThreadRef; server: PreviewableServer; onOpen: () => void; } -export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) { - const subtitle = describeServer(server); +/** One live local server: a live dot, its address, the owning process, and Open. */ +export function PreviewLocalServerCard({ server, onOpen }: Props) { + const address = `${server.host}:${server.port}`; return ( - } - title={subtitle} - description={`${server.host}:${server.port}`} - /> +
+ +
+ {address} + {describeServer(server)} +
+ +
); } diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx index 6da5d5c2ed330..8814d3561ece1 100644 --- a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -1,20 +1,17 @@ -import { Tooltip, TooltipTrigger, TooltipPopup } from "~/components/ui/tooltip"; -import type { ScopedThreadRef } from "@t3tools/contracts"; -import { X } from "lucide-react"; +import { History, X } from "lucide-react"; import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore"; import { RelativeTime } from "~/components/RelativeTime"; - -import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; interface Props { - threadRef: ScopedThreadRef; entry: BrowserHistoryEntry; onOpen: () => void; onRemove: () => void; } -export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Props) { +/** A plain history row: page title, then its address; the visit time is in the tooltip. */ +export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { const parsed = new URL(entry.url); const path = parsed.pathname === "/" ? "" : parsed.pathname; const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`; @@ -23,29 +20,46 @@ export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Pro ) : null; return (
- + + + {visitedAt ? ( + + {entry.url} + {visitedAt} + + ) : ( + entry.url + )} + + diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx index e3a45f11cd3c1..80f8d83d02ef0 100644 --- a/apps/web/src/components/preview/PreviewUnreachable.tsx +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { Dot, ExternalLink, FileDown, RotateCw, Unplug } from "lucide-react"; import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { describePreviewError } from "./errorCodeMessages"; @@ -9,9 +10,19 @@ interface Props { code: number; description: string; onReload: () => void; + /** Hands the URL to the system browser; omitted where there is no shell. */ + onOpenInBrowser?: (() => void) | undefined; move?: { readonly label: string; readonly onMove: () => void }; } +const SUGGESTIONS = [ + "Confirming the dev server is running", + "Checking the proxy and the firewall", + "Checking your connection", +] as const; + +const MOVE_SUGGESTION = "Opening it in the environment’s browser, which reaches its network"; + export function PreviewFileNotShown({ url, fileName, @@ -26,18 +37,19 @@ export function PreviewFileNotShown({ const host = safeHost(url) ?? url; return (
-
- -

+
+ + + +

This file can’t be shown here

-

- {fileName} from {host} was downloaded - instead. +

+ {fileName} from {host} was downloaded instead.

-
-
+
{downloadUrl ? ( @@ -51,56 +63,73 @@ export function PreviewFileNotShown({ ); } -export function PreviewUnreachable({ url, code, description, onReload, move }: Props) { - const [showDetails, setShowDetails] = useState(false); +/** + * Shown over the webview when a page fails to load. The friendly reason and the + * raw net error code live in the title's tooltip. + */ +export function PreviewUnreachable({ + url, + code, + description, + onReload, + onOpenInBrowser, + move, +}: Props) { const host = safeHost(url) ?? url; const friendly = describePreviewError(description); const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; + const suggestions = move ? [...SUGGESTIONS, MOVE_SUGGESTION] : SUGGESTIONS; return (
-
- -

- This site can’t be reached -

-

- {host}: {friendly}. -

- - {showDetails ? ( -
-

Try:

-
    -
  • Checking your connection
  • -
  • Confirming the dev server is running
  • - {move ? ( -
  • Opening it in the environment’s browser, which reaches its network
  • - ) : null} -
  • Checking the proxy and the firewall
  • -
-
- ) : null} - -
{errorLabel}
- -
- -
+ {host} isn’t responding + + + + {friendly} + {errorLabel} + + + +
+

Try:

+
    + {suggestions.map((suggestion) => ( +
  • + + {suggestion} +
  • + ))} +
+
+
+ {onOpenInBrowser ? ( + + ) : null} {move ? ( ) : null}
@@ -108,22 +137,6 @@ export function PreviewUnreachable({ url, code, description, onReload, move }: P ); } -function ErrorIcon({ className }: { className?: string }) { - return ( - - - - - - ); -} - function safeHost(url: string): string | null { try { return new URL(url).host; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 1046500ed9caf..b03128ef53b49 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -448,6 +448,10 @@ export function PreviewView({ void localApi.shell.openExternal(url).catch(() => undefined); }; + const handleFocusUrl = () => { + urlInputRef.current?.focus(); + }; + const moveTarget = alternatePreviewRuntime( threadRef.environmentId, primaryEnvironmentId, @@ -1120,12 +1124,12 @@ export function PreviewView({ ) : null} {showEmptyState ? ( removeUrlForThread(threadRef, url)} onOpenUrl={(next) => void handleOpenServerUrl(next)} + onFocusUrl={handleFocusUrl} /> ) : null} {snapshot && desktopOverlay ? ( @@ -1166,6 +1170,7 @@ export function PreviewView({ code={navStatus.code} description={navStatus.description} onReload={handleRefresh} + onOpenInBrowser={localApi ? handleOpenInBrowser : undefined} {...(moveTarget === "server" ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } } : {})} diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx index 521d9c47f316e..4ba073f1fd30b 100644 --- a/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx @@ -1,5 +1,5 @@ import "~/index.css"; -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { Cause } from "effect"; import { expect, it, vi } from "vite-plus/test"; @@ -39,6 +39,28 @@ const environmentId = EnvironmentId.make("environment"); type Detail = Parameters[0]["detail"]; const openDetail: Detail = { + provider: SourceControlProviderKind.make("github"), + projectId: reference.projectId, + projectTitle: "Project", + workspaceRoot: "/workspace", + repository: reference.repository, + number: reference.number, + title: "Test pull request", + body: "Original description", + author: { login: "author", name: null, avatarUrl: null }, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + baseBranch: "main", + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: false, squash: false, rebase: false }, url: "https://github.com/owner/repo/pull/7", state: "open", isDraft: false, @@ -111,6 +133,15 @@ function setup() { perform={async () => true} openOnHostText="Open on GitHub" copyReference={() => {}} + inlineConfirm={{ + isArmed: () => false, + props: () => ({ ref: undefined, onClick: () => {}, onBlur: () => {} }), + }} + showCheckout={false} + checkoutMenuItems={null} + onEditTitle={() => {}} + repositoryUrl={null} + checkoutCommand={null} /> ); } diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx index 9d80d040592f9..e1f053e52b93b 100644 --- a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx @@ -1,3 +1,4 @@ +import { useId, useState, type ReactNode } from "react"; import { useAtomValue } from "@effect/atom-react"; import { canEditPullRequestMaintainerEdits, @@ -25,9 +26,10 @@ import { RotateCcwIcon, ScrollTextIcon, Trash2Icon, + FolderGit2Icon, + PencilIcon, + SquareTerminalIcon, } from "lucide-react"; -import { useId, useState } from "react"; - import { shortcutLabelForCommand } from "~/keybindings"; import { readLocalApi } from "~/localApi"; import { pullRequestEnvironment } from "~/state/pullRequests"; @@ -44,6 +46,8 @@ import { MenuSeparator, MenuShortcut, MenuTrigger, + MenuGroup, + MenuGroupLabel, } from "../ui/menu"; import { RefreshIcon } from "../ui/refresh-icon"; import { toastManager } from "../ui/toast"; @@ -55,9 +59,10 @@ import { readableFailure, } from "./pullRequestDetail.logic"; import { PullRequestGlyph } from "./pullRequestIcons"; -import type { PickableEnvironment } from "./pullRequestProjectAssignment.logic"; +import { type PickableEnvironment } from "./pullRequestProjectAssignment.logic"; import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { useInlineActionConfirm } from "./useInlineActionConfirm"; +import { canEditPullRequestChangeRequest } from "@t3tools/client-runtime/pull-request-editing"; /** Everything else the reader can do with the pull request, behind the header's overflow button. */ export function PullRequestActionsMenu({ @@ -102,21 +107,17 @@ export function PullRequestActionsMenu({ perform, openOnHostText, copyReference, + inlineConfirm, + showCheckout, + checkoutMenuItems, + onEditTitle, + repositoryUrl, + checkoutCommand, }: { environmentId: EnvironmentId; reference: PullRequestRef; pullRequestKey: string; - detail: Pick< - PullRequestDetail, - | "url" - | "state" - | "isDraft" - | "headBranch" - | "capabilities" - | "viewerPermissions" - | "maintainerCanModify" - | "observedAt" - >; + detail: PullRequestDetail; terminology: ChangeRequestTerminology; linkThreadRef: ScopedThreadRef | null; onThreadPickerOpenChange: (open: boolean) => void; @@ -154,9 +155,14 @@ export function PullRequestActionsMenu({ perform: PerformPullRequestAction; openOnHostText: string; copyReference: (value: string, label: string) => void; + inlineConfirm: ReturnType; + showCheckout: boolean; + checkoutMenuItems: ReactNode; + onEditTitle: () => void; + repositoryUrl: string | null; + checkoutCommand: string | null; }) { const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const inlineConfirm = useInlineActionConfirm(pullRequestKey); const actionPending = pendingAction !== null; const maintainerEditsHintId = useId(); const updatePullRequest = useAtomCommand(pullRequestEnvironment.update, { @@ -248,7 +254,7 @@ export function PullRequestActionsMenu({ {handoff === "ask" ? "Opening..." : "Ask a question"} - + {attachesToComposer ? `Adds the ${terminology.singular} to this thread's composer.` : `Opens a thread that knows which ${terminology.singular} you mean.`} @@ -261,7 +267,7 @@ export function PullRequestActionsMenu({ {handoff === "explain" ? "Opening..." : `Explain this ${terminology.shortLabel}`} - + A walk through the diff and what to read closely. @@ -276,11 +282,17 @@ export function PullRequestActionsMenu({ {guideRunning ? "Writing guide..." : !guideWritten ? "Write guide" : "Rewrite guide"} ) : null} + {showCheckout ? ( + <> + + {checkoutMenuItems} + + ) : null} {pickableEnvironments.length > 0 ? ( onActingEnvironmentChange(next)} disabled={handoff !== null} /> ) : null} @@ -366,24 +378,30 @@ export function PullRequestActionsMenu({ {showsMergeMethods ? ( <> {showsDraftToggle || showsMergeNow || showsAutoMerge ? : null} - onSelectMergeMethod(method as PullRequestMergeMethod)} - > - {allowedMergeMethods.map((method) => ( - - - - {PULL_REQUEST_MERGE_METHOD_LABELS[method]} - - - ))} - + + Merge method + { + const selectedMethod = method as PullRequestMergeMethod; + onSelectMergeMethod(selectedMethod); + }} + > + {allowedMergeMethods.map((method) => ( + + + + {PULL_REQUEST_MERGE_METHOD_LABELS[method]} + + + ))} + + ) : null} {pullRequestActionMenuHasGroup( @@ -395,10 +413,22 @@ export function PullRequestActionsMenu({ ) : null} ) : null} + {canEditPullRequestChangeRequest(detail) ? ( + onEditTitle()}> + + Edit title + + ) : null} void readLocalApi()?.shell.openExternal(detail.url)}> {openOnHostText} + {repositoryUrl ? ( + void readLocalApi()?.shell.openExternal(repositoryUrl)}> + + Open {detail.repository} + + ) : null} copyReference(detail.url, `${terminology.shortLabel} link`)}> Copy link @@ -415,6 +445,12 @@ export function PullRequestActionsMenu({ {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} + {checkoutCommand ? ( + copyReference(checkoutCommand, "Checkout command")}> + + Copy checkout command + + ) : null} {detail.state === "open" && can("close") ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestBaseBranch.tsx b/apps/web/src/components/pullRequest/PullRequestBaseBranch.tsx index 3b35103a9f705..65df4dc111e74 100644 --- a/apps/web/src/components/pullRequest/PullRequestBaseBranch.tsx +++ b/apps/web/src/components/pullRequest/PullRequestBaseBranch.tsx @@ -1,7 +1,8 @@ -import type { PullRequestUpdateMethod } from "@t3tools/contracts"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { type PullRequestUpdateMethod } from "@t3tools/contracts"; import { TriangleAlertIcon } from "lucide-react"; -import type { ReactNode } from "react"; - +import { type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { MiddleTruncate } from "../ui/middle-truncate"; @@ -14,6 +15,27 @@ interface BaseFreshness { readonly methods: ReadonlyArray; } +function PullRequestFreshnessTrigger({ + className, + render, + ...props +}: useRender.ComponentProps<"button">) { + return useRender({ + defaultTagName: "button", + render, + props: mergeProps<"button">( + { + type: "button", + className: cn( + "inline-flex min-w-0 shrink-0 cursor-help items-center gap-1 rounded-sm text-warning-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + className, + ), + }, + props, + ), + }); +} + function PullRequestBaseFreshnessWarning({ baseBranch, freshness, @@ -44,23 +66,14 @@ function PullRequestBaseFreshnessWarning({ openOnHover delay={0} closeDelay={120} - render={ - + + + {item.type === "diff" ? ( + <> + + + + ) : null} + ); }; + const renderHeaderFilenameSuffix = (item: CodeViewItem) => ( + + ); + const renderHeaderMetadata = (item: CodeViewItem) => { if (item.type !== "diff") return null; let { additions, deletions } = sourceFileStats.get(item.id) ?? getDiffLineStat([item.fileDiff]); @@ -1143,13 +1177,7 @@ function PullRequestCodeTab({ - } + stat={} /> ); }; @@ -1306,69 +1334,73 @@ function PullRequestCodeTab({ /> ))} {annotation.metadata.draft && draft && canSelectLines ? ( - - finishSelection(draft, text, (comment) => - onAddToAgentSelection({ comment, request: text }), - ), - }, - } - : {})} - onCancel={() => { - setDraft(null); - setSelectedLines(null); - }} - onComment={(body) => { - if (!canCommentOnLines) { - if (onAddToAgentSelection) { - finishSelection(draft, body, (comment) => - onAddToAgentSelection({ comment, request: body }), - ); +
+ + finishSelection(draft, text, (comment) => + onAddToAgentSelection({ comment, request: text }), + ), + }, + } + : {})} + onCancel={() => { + setDraft(null); + setSelectedLines(null); + }} + onComment={(body) => { + if (!canCommentOnLines) { + if (onAddToAgentSelection) { + finishSelection(draft, body, (comment) => + onAddToAgentSelection({ comment, request: body }), + ); + } + return; } - return; - } - addComment(reviewKey, { - id: nextPendingReviewCommentId(), - path: draft.path, - ...(draft.oldPath === null ? {} : { oldPath: draft.oldPath }), - ...(draft.startPosition === undefined ? {} : { startPosition: draft.startPosition }), - position: draft.position, - body, - }); - setDraft(null); - setSelectedLines(null); - }} - /> + addComment(reviewKey, { + id: nextPendingReviewCommentId(), + path: draft.path, + ...(draft.oldPath === null ? {} : { oldPath: draft.oldPath }), + ...(draft.startPosition === undefined + ? {} + : { startPosition: draft.startPosition }), + position: draft.position, + body, + }); + setDraft(null); + setSelectedLines(null); + }} + /> +
) : null}
); const selectedCommit = orderedCommits.find((entry) => entry.oid === commit); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; - const changeIgnoreWhitespace = (next: boolean) => { - setIgnoreWhitespace(next); - setDraft(null); - setSelectedLines(null); - }; const toolbar = ( -
-
+
+
{orderedCommits.length > 0 ? ( } + render={ ) : null} {filesViewed.enabled && files.length > 0 ? ( {filePaths.filter(isFileViewed).length} / {files.length} + {nextCursor === null ? "" : "+"} {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} @@ -1501,183 +1544,32 @@ function PullRequestCodeTab({ ) : null} - +
- - - } - className="@lg:hidden" - aria-label="Diff options" - /> - } - > - - - Diff options - - - changeIgnoreWhitespace(checked)} - > - Hide whitespace changes - - setWordWrapOverride(checked)} - > - Wrap lines - - - { - if (value === "stacked" || value === "split") { - updateClientSettings({ diffLayout: value }); - } - }} - > - - Stacked - - - Split - - - - -
- - changeIgnoreWhitespace(Boolean(pressed))} - /> - } - > - - - - {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} - - - { - const next = value[0]; - if (next === "stacked" || next === "split") { - updateClientSettings({ diffLayout: next }); - } - }} - > - - - - - - - - - { - setWordWrapOverride(Boolean(pressed)); - }} - /> - } - > - - - - {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} - - -
- {fileKeys.length > 0 ? ( - - - } - > - {allFilesCollapsed ? ( - - ) : ( - - )} - - - {allFilesCollapsed ? "Expand all files" : "Collapse all files"} - - - ) : null} - {testFileCount > 0 ? ( - - setHideTestFiles(Boolean(pressed))} - /> - } - > - - - - {hideTestFiles ? "Show test files" : "Hide test files"} - - - ) : null} - {fileKeys.length > 0 ? ( - - setFileTreeOpen(Boolean(pressed))} - /> - } - > - - - - {fileTreeOpen ? "Hide file tree" : "Show file tree"} - - - ) : null} + { + setIgnoreWhitespace(next); + setDraft(null); + setSelectedLines(null); + }} + hasFiles={fileKeys.length > 0} + testFileCount={testFileCount} + hideTestFiles={hideTestFiles} + onHideTestFilesChange={setHideTestFiles} + fileTreeOpen={fileTreeOpen} + onFileTreeOpenChange={setFileTreeOpen} + allFilesCollapsed={allFilesCollapsed} + onToggleAllFiles={toggleAllFiles} + />
); + const toolbarView = toolbarContainer ? createPortal(toolbar, toolbarContainer) : toolbar; // A file not loaded yet pages forward each time a slice arrives; once loaded, the viewer // positions it when it renders it. const answerReveal = useEffectEvent(() => { @@ -1749,7 +1641,7 @@ function PullRequestCodeTab({ }, [revealUrl, pendingLinkPath, canLoadNextSlice, items]); const withToolbar = (body: ReactNode) => (
- {toolbar} + {toolbarView}
{body}
@@ -1778,7 +1670,7 @@ function PullRequestCodeTab({ if (items.length === 0 && nextCursor === null) { return withToolbar( -

+

{commit === null ? `This ${term} has no file changes.` : "This commit has no file changes."}

, ); @@ -1794,12 +1686,12 @@ function PullRequestCodeTab({ return (
- {toolbar} + {toolbarView} {orphanFiles.size > 0 ? (

- + {nextCursor === null ? "Conversations not on the current diff" @@ -1826,7 +1718,7 @@ function PullRequestCodeTab({ {path}

+

{path}

} /> {path} @@ -1835,7 +1727,9 @@ function PullRequestCodeTab({ {threads.map((thread) => (
{thread.line === null ? null : ( -

Line {thread.line}

+

+ Line {thread.line} +

)} {renderThreadCard(thread)}
@@ -1879,8 +1773,8 @@ function PullRequestCodeTab({ {/* The viewer must own its scroll element to position virtual rows within it. */} - className="@container h-full overflow-auto [scrollbar-gutter:stable]" - containerRef={scrollerRef} + className="@container h-full overflow-auto [--surface-top-inset:0px] [scrollbar-gutter:stable]" + {...(scrollerRef ? { containerRef: scrollerRef } : {})} viewerRef={codeViewReveal.attach} items={items} selectedLines={selectedLines} @@ -1888,9 +1782,10 @@ function PullRequestCodeTab({ options={diffViewOptions} renderCodeViewFooter={renderCodeViewFooter} renderHeaderPrefix={renderHeaderPrefix} + renderHeaderFilenameSuffix={renderHeaderFilenameSuffix} renderHeaderMetadata={renderHeaderMetadata} renderAnnotation={renderAnnotation} - unsafeCSSExtra={`${REPLACE_FILE_COUNTS_CSS}\n${CUT_LINES_CSS}`} + unsafeCSSExtra={CUT_LINES_CSS} />

diff --git a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx index 8364691e5a351..c22fa28489e8b 100644 --- a/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCommentForm.tsx @@ -16,6 +16,7 @@ import { } from "./PullRequestMarkdownTextarea"; import { pullRequestReviewKey } from "./pullRequestReviewStore"; import { useInlineActionConfirm } from "./useInlineActionConfirm"; +import { PullRequestSubmitKeyHint } from "./PullRequestSubmitKeyHint"; export function PullRequestCommentForm({ environmentId, @@ -175,12 +176,12 @@ export function PullRequestCommentForm({ )}
diff --git a/apps/web/src/components/pullRequest/PullRequestCompactFilterMenu.tsx b/apps/web/src/components/pullRequest/PullRequestCompactFilterMenu.tsx index 38f34dbef0a8c..86ab1309eb209 100644 --- a/apps/web/src/components/pullRequest/PullRequestCompactFilterMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCompactFilterMenu.tsx @@ -20,10 +20,13 @@ export function PullRequestCompactFilterMenu({ options, onChange, className, + labelClassName, }: { label: string; triggerIcon?: ReactNode; triggerLabel?: string; + /** Hides the words and chevron in a narrow column, leaving the icon. */ + labelClassName?: string; outlined?: boolean; iconOnly?: boolean; value: Value; @@ -49,15 +52,14 @@ export function PullRequestCompactFilterMenu({ > {iconOnly ? ( - ) : triggerLabel ? ( - <> - {triggerIcon} - {triggerLabel} - ) : ( <> - {current.label} - + {triggerIcon ?? } + {triggerLabel ?? current.label} + )} diff --git a/apps/web/src/components/pullRequest/PullRequestComposer.tsx b/apps/web/src/components/pullRequest/PullRequestComposer.tsx index 102ccded8aa9e..eb772c8ca8c7c 100644 --- a/apps/web/src/components/pullRequest/PullRequestComposer.tsx +++ b/apps/web/src/components/pullRequest/PullRequestComposer.tsx @@ -2,8 +2,14 @@ import { capitalizeTerm, sourceControlClients, } from "@t3tools/client-runtime/source-control-clients"; -import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; -import { MessageSquareIcon, Trash2Icon, XIcon } from "lucide-react"; +/** Comment and review drafts stay separate across verdict switches and dock dismissal. */ +import type { + EnvironmentId, + PullRequestDetailView, + PullRequestRef, + PullRequestReviewVerdict, +} from "@t3tools/contracts"; +import { MessageSquareIcon, MessageSquareTextIcon, XIcon } from "lucide-react"; import { useRef, useState } from "react"; import { Button } from "../ui/button"; @@ -43,11 +49,11 @@ export function PullRequestComposer({ const term = sourceControlClients.get(detail.provider).changeRequest.singular; const [open, setOpen] = useState(false); const [reviewPending, setReviewPending] = useState(false); - const [requestedMode, setRequestedMode] = useState<"comment" | "review">("comment"); + const [reviewRequested, setReviewRequested] = useState(false); + const [requestedMode, setRequestedMode] = useState("comment"); const commentRef = useRef(null); const reviewRef = useRef(null); const pendingComments = usePendingReviewComments(reference); - const clearComments = usePullRequestReviewStore((store) => store.clear); // Unsent summaries outlive the popover; select a boolean to avoid per-keystroke renders. const reviewKey = pullRequestReviewKey(reference); const summaryStarted = usePullRequestReviewStore( @@ -57,7 +63,7 @@ export function PullRequestComposer({ const [answeredOpenRequest, setAnsweredOpenRequest] = useState(openReviewRequest); if (openReviewRequest !== answeredOpenRequest) { setAnsweredOpenRequest(openReviewRequest); - setRequestedMode("review"); + setReviewRequested(true); setOpen(true); } @@ -67,13 +73,49 @@ export function PullRequestComposer({ ); if (!canComment && verdicts.length === 0) return null; - const mode = canComment ? (verdicts.length === 0 ? "comment" : requestedMode) : "review"; + // Comment is offered wherever a plain remark or a commented review can be sent. + const modes = (["comment", "approve", "request-changes"] as const).filter((mode) => + mode === "comment" + ? (!reviewRequested && canComment) || verdicts.includes("comment") + : verdicts.includes(mode), + ); + const mode = modes.includes(requestedMode) ? requestedMode : (modes[0] ?? "comment"); + // A review is what is sent when line comments or a summary have been started. + const sendsReview = + mode !== "comment" || + !canComment || + (verdicts.includes("comment") && (reviewStarted || reviewRequested)); + + const close = ( + } aria-label="Close composer"> + + + ); + const modePicker = + modes.length > 1 ? ( + { + const value = modes.find((candidate) => candidate === next[0]); + if (value) setRequestedMode(value); + }} + > + {modes.map((value) => ( + + {value === "comment" ? "Comment" : value === "approve" ? "Approve" : "Request changes"} + + ))} + + ) : null; return ( { - if (next) setRequestedMode(reviewStarted ? "review" : "comment"); + // The last mode is kept: line comments waiting already turn Comment into the review. + if (!next) setReviewRequested(false); setOpen(next); }} > @@ -91,7 +133,7 @@ export function PullRequestComposer({ {pendingComments.length > 0 ? ( {pendingComments.length} @@ -103,61 +145,42 @@ export function PullRequestComposer({ align="end" sideOffset={8} width="lg" - initialFocus={mode === "review" ? reviewRef : commentRef} + initialFocus={sendsReview ? reviewRef : commentRef} aria-label={`${capitalizeTerm(term)} composer`} > -
- {canComment && verdicts.length > 0 ? ( - { - const value = next[0]; - if (value === "comment" || value === "review") setRequestedMode(value); - }} - > - Comment - - {pendingComments.length > 0 ? `Review (${pendingComments.length})` : "Review"} - - - ) : ( - - {mode === "review" ? `Review ${term}` : `Comment on ${term}`} - - )} -
- {mode === "review" && pendingComments.length > 0 ? ( - - ) : null} - } - aria-label="Close composer" - > - - + {/* With line comments waiting, the review header takes the title row and the verdicts + sit under it; otherwise the verdicts, or the plain title, share the row with close. */} + {pendingComments.length > 0 ? ( + <> +
+ + Your review + + {pendingComments.length} pending{" "} + {pendingComments.length === 1 ? "comment" : "comments"} + + {close} +
+ {modePicker ?
{modePicker}
: null} + + ) : ( +
+ {modePicker ?? ( + {sendsReview ? `Review ${term}` : `Comment on ${term}`} + )} + {close}
-
+ )} {/* Keep forms mounted to preserve drafts and submit guards across dismissal. */} {verdicts.length > 0 ? ( -