diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index aff0fe55e0cf..72f270dca3f5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -28,3 +28,4 @@ github:realAhmedRoach github:shiroyasha9 github:Yash-Singh1 github:eggfriedrice24 +github:Ymit24 \ No newline at end of file diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 80488c4897da..ef6015d88ed5 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -97,7 +97,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => return { status: (input) => core.status(input), statusDetails: (cwd) => core.statusDetails(cwd), - prepareCommitContext: (cwd) => core.prepareCommitContext(cwd), + prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), commit: (cwd, subject, body) => core.commit(cwd, subject, body), pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), @@ -1712,6 +1712,45 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("prepareCommitContext stages only selected files when filePaths provided", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); + yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); + + const context = yield* core.prepareCommitContext(tmp, ["a.txt"]); + expect(context).not.toBeNull(); + expect(context!.stagedSummary).toContain("a.txt"); + expect(context!.stagedSummary).not.toContain("b.txt"); + + yield* core.commit(tmp, "Add only a.txt", ""); + + // b.txt should still be untracked after commit + const statusAfter = yield* git(tmp, ["status", "--porcelain"]); + expect(statusAfter).toContain("b.txt"); + expect(statusAfter).not.toContain("a.txt"); + }), + ); + + it.effect("prepareCommitContext stages everything when filePaths is undefined", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); + yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); + + const context = yield* core.prepareCommitContext(tmp); + expect(context).not.toBeNull(); + expect(context!.stagedSummary).toContain("a.txt"); + expect(context!.stagedSummary).toContain("b.txt"); + }), + ); + it.effect("pushes with upstream setup and then skips when up to date", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 9322bbe7e294..67fad5b80f20 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -846,9 +846,21 @@ const makeGitCore = Effect.gen(function* () { })), ); - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => + const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => Effect.gen(function* () { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + if (filePaths && filePaths.length > 0) { + yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catch(() => Effect.void), + ); + yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ "diff", diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index cc80eda23b20..8c72941cd0e2 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -451,6 +451,7 @@ function runStackedAction( action: "commit" | "commit_push" | "commit_push_pr"; commitMessage?: string; featureBranch?: boolean; + filePaths?: readonly string[]; }, ) { return manager.runStackedAction(input); @@ -767,6 +768,31 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("commits only selected files when filePaths is provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); + fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + filePaths: ["a.txt"], + }); + + expect(result.commit.status).toBe("created"); + + // b.txt should remain in the working tree + const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((r) => r.stdout), + ); + expect(statusStdout).toContain("b.txt"); + expect(statusStdout).not.toContain("a.txt"); + }), + ); + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a3412249fb12..635c5407bf10 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -638,9 +638,10 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string; /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; + filePaths?: readonly string[]; }) => Effect.gen(function* () { - const context = yield* gitCore.prepareCommitContext(input.cwd); + const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); if (!context) { return null; } @@ -680,6 +681,7 @@ export const makeGitManager = Effect.gen(function* () { branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, + filePaths?: readonly string[], ) => Effect.gen(function* () { const suggestion = @@ -688,6 +690,7 @@ export const makeGitManager = Effect.gen(function* () { cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), })); if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -971,12 +974,18 @@ export const makeGitManager = Effect.gen(function* () { }, ); - const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => + const runFeatureBranchStep = ( + cwd: string, + branch: string | null, + commitMessage?: string, + filePaths?: readonly string[], + ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), includeBranch: true, }); if (!suggestion) { @@ -1025,6 +1034,7 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, initialStatus.branch, input.commitMessage, + input.filePaths, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -1040,6 +1050,7 @@ export const makeGitManager = Effect.gen(function* () { currentBranch, commitMessageForStep, preResolvedCommitSuggestion, + input.filePaths, ); const push = wantsPush diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 392daddf6213..85bb1a0a3646 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -104,6 +104,7 @@ export interface GitCoreShape { */ readonly prepareCommitContext: ( cwd: string, + filePaths?: readonly string[], ) => Effect.Effect; /** diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index 82804cec7861..daa58d0f1205 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,111 +7,114 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = "2.12.10"; -const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; -const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); -const activeClientIds = new Set(); +const PACKAGE_VERSION = '2.12.10' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() -addEventListener("install", function () { - self.skipWaiting(); -}); +addEventListener('install', function () { + self.skipWaiting() +}) -addEventListener("activate", function (event) { - event.waitUntil(self.clients.claim()); -}); +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) -addEventListener("message", async function (event) { - const clientId = Reflect.get(event.source || {}, "id"); +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { - return; + return } - const client = await self.clients.get(clientId); + const client = await self.clients.get(clientId) if (!client) { - return; + return } const allClients = await self.clients.matchAll({ - type: "window", - }); + type: 'window', + }) switch (event.data) { - case "KEEPALIVE_REQUEST": { + case 'KEEPALIVE_REQUEST': { sendToClient(client, { - type: "KEEPALIVE_RESPONSE", - }); - break; + type: 'KEEPALIVE_RESPONSE', + }) + break } - case "INTEGRITY_CHECK_REQUEST": { + case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { - type: "INTEGRITY_CHECK_RESPONSE", + type: 'INTEGRITY_CHECK_RESPONSE', payload: { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, - }); - break; + }) + break } - case "MOCK_ACTIVATE": { - activeClientIds.add(clientId); + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) sendToClient(client, { - type: "MOCKING_ENABLED", + type: 'MOCKING_ENABLED', payload: { client: { id: client.id, frameType: client.frameType, }, }, - }); - break; + }) + break } - case "CLIENT_CLOSED": { - activeClientIds.delete(clientId); + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) const remainingClients = allClients.filter((client) => { - return client.id !== clientId; - }); + return client.id !== clientId + }) // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister(); + self.registration.unregister() } - break; + break } } -}); +}) -addEventListener("fetch", function (event) { - const requestInterceptedAt = Date.now(); +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() // Bypass navigation requests. - if (event.request.mode === "navigate") { - return; + if (event.request.mode === 'navigate') { + return } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { - return; + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { - return; + return } - const requestId = crypto.randomUUID(); - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); -}); + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) /** * @param {FetchEvent} event @@ -119,23 +122,28 @@ addEventListener("fetch", function (event) { * @param {number} requestInterceptedAt */ async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event); - const requestCloneForEvents = event.request.clone(); - const response = await getResponse(event, client, requestId, requestInterceptedAt); + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents); + const serializedRequest = await serializeRequest(requestCloneForEvents) // Clone the response so both the client and the library could consume it. - const responseClone = response.clone(); + const responseClone = response.clone() sendToClient( client, { - type: "RESPONSE", + type: 'RESPONSE', payload: { isMockedResponse: IS_MOCKED_RESPONSE in response, request: { @@ -152,10 +160,10 @@ async function handleRequest(event, requestId, requestInterceptedAt) { }, }, responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ); + ) } - return response; + return response } /** @@ -167,30 +175,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) { * @returns {Promise} */ async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId); + const client = await self.clients.get(event.clientId) if (activeClientIds.has(event.clientId)) { - return client; + return client } - if (client?.frameType === "top-level") { - return client; + if (client?.frameType === 'top-level') { + return client } const allClients = await self.clients.matchAll({ - type: "window", - }); + type: 'window', + }) return allClients .filter((client) => { // Get only those clients that are currently visible. - return client.visibilityState === "visible"; + return client.visibilityState === 'visible' }) .find((client) => { // Find the client ID that's recorded in the // set of clients that have registered the worker. - return activeClientIds.has(client.id); - }); + return activeClientIds.has(client.id) + }) } /** @@ -203,34 +211,36 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone(); + const requestClone = event.request.clone() function passthrough() { // Cast the request headers to a new Headers instance // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers); + const headers = new Headers(requestClone.headers) // Remove the "accept" header value that marked this request as passthrough. // This prevents request alteration and also keeps it compliant with the // user-defined CORS policies. - const acceptHeader = headers.get("accept"); + const acceptHeader = headers.get('accept') if (acceptHeader) { - const values = acceptHeader.split(",").map((value) => value.trim()); - const filteredValues = values.filter((value) => value !== "msw/passthrough"); + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) if (filteredValues.length > 0) { - headers.set("accept", filteredValues.join(", ")); + headers.set('accept', filteredValues.join(', ')) } else { - headers.delete("accept"); + headers.delete('accept') } } - return fetch(requestClone, { headers }); + return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. if (!client) { - return passthrough(); + return passthrough() } // Bypass initial page load requests (i.e. static assets). @@ -238,15 +248,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return passthrough(); + return passthrough() } // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request); + const serializedRequest = await serializeRequest(event.request) const clientMessage = await sendToClient( client, { - type: "REQUEST", + type: 'REQUEST', payload: { id: requestId, interceptedAt: requestInterceptedAt, @@ -254,19 +264,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { }, }, [serializedRequest.body], - ); + ) switch (clientMessage.type) { - case "MOCK_RESPONSE": { - return respondWithMock(clientMessage.data); + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) } - case "PASSTHROUGH": { - return passthrough(); + case 'PASSTHROUGH': { + return passthrough() } } - return passthrough(); + return passthrough() } /** @@ -277,18 +287,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { - const channel = new MessageChannel(); + const channel = new MessageChannel() channel.port1.onmessage = (event) => { if (event.data && event.data.error) { - return reject(event.data.error); + return reject(event.data.error) } - resolve(event.data); - }; + resolve(event.data) + } - client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); - }); + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) } /** @@ -301,17 +314,17 @@ function respondWithMock(response) { // instance will have status code set to 0. Since it's not possible to create // a Response instance with status code 0, handle that use-case separately. if (response.status === 0) { - return Response.error(); + return Response.error() } - const mockedResponse = new Response(response.body, response); + const mockedResponse = new Response(response.body, response) Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, enumerable: true, - }); + }) - return mockedResponse; + return mockedResponse } /** @@ -332,5 +345,5 @@ async function serializeRequest(request) { referrerPolicy: request.referrerPolicy, body: await request.arrayBuffer(), keepalive: request.keepalive, - }; + } } diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 213e4cd3d41f..e7e9a67e164f 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { getAppModelOptions, - getSlashModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, } from "./appSettings"; @@ -58,17 +57,3 @@ describe("resolveAppModelSelection", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); }); }); - -describe("getSlashModelOptions", () => { - it("includes saved custom model slugs for /model command suggestions", () => { - const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex"); - - expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); - }); - - it("filters slash-model suggestions across built-in and custom model names", () => { - const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); - - expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); - }); -}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 16a99168565d..29febb2bd8c0 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,8 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { useLocalStorage } from "./hooks/useLocalStorage"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -35,10 +36,6 @@ export interface AppModelOption { const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); -let listeners: Array<() => void> = []; -let cachedRawSettings: string | null | undefined; -let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; - export function normalizeCustomModelSlugs( models: Iterable, provider: ProviderKind = "codex", @@ -68,13 +65,6 @@ export function normalizeCustomModelSlugs( return normalizedModels; } -function normalizeAppSettings(settings: AppSettings): AppSettings { - return { - ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), - }; -} - export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -144,112 +134,26 @@ export function resolveAppModelSelection( ); } -export function getSlashModelOptions( - provider: ProviderKind, - customModels: readonly string[], - query: string, - selectedModel?: string | null, -): AppModelOption[] { - const normalizedQuery = query.trim().toLowerCase(); - const options = getAppModelOptions(provider, customModels, selectedModel); - if (!normalizedQuery) { - return options; - } - - return options.filter((option) => { - const searchSlug = option.slug.toLowerCase(); - const searchName = option.name.toLowerCase(); - return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); - }); -} - -function emitChange(): void { - for (const listener of listeners) { - listener(); - } -} - -function parsePersistedSettings(value: string | null): AppSettings { - if (!value) { - return DEFAULT_APP_SETTINGS; - } - - try { - return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); - } catch { - return DEFAULT_APP_SETTINGS; - } -} - -export function getAppSettingsSnapshot(): AppSettings { - if (typeof window === "undefined") { - return DEFAULT_APP_SETTINGS; - } - - const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); - if (raw === cachedRawSettings) { - return cachedSnapshot; - } - - cachedRawSettings = raw; - cachedSnapshot = parsePersistedSettings(raw); - return cachedSnapshot; -} - -function persistSettings(next: AppSettings): void { - if (typeof window === "undefined") return; - - const raw = JSON.stringify(next); - try { - if (raw !== cachedRawSettings) { - window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); - } - } catch { - // Best-effort persistence only. - } - - cachedRawSettings = raw; - cachedSnapshot = next; -} - -function subscribe(listener: () => void): () => void { - listeners.push(listener); - - const onStorage = (event: StorageEvent) => { - if (event.key === APP_SETTINGS_STORAGE_KEY) { - emitChange(); - } - }; - - window.addEventListener("storage", onStorage); - return () => { - listeners = listeners.filter((entry) => entry !== listener); - window.removeEventListener("storage", onStorage); - }; -} - export function useAppSettings() { - const settings = useSyncExternalStore( - subscribe, - getAppSettingsSnapshot, - () => DEFAULT_APP_SETTINGS, + const [settings, setSettings] = useLocalStorage( + APP_SETTINGS_STORAGE_KEY, + DEFAULT_APP_SETTINGS, + AppSettingsSchema, ); - const updateSettings = useCallback((patch: Partial) => { - const next = normalizeAppSettings( - Schema.decodeSync(AppSettingsSchema)({ - ...getAppSettingsSnapshot(), + const updateSettings = useCallback( + (patch: Partial) => { + setSettings((prev) => ({ + ...prev, ...patch, - }), - ); - persistSettings(next); - emitChange(); - }, []); + })); + }, + [setSettings], + ); const resetSettings = useCallback(() => { - persistSettings(DEFAULT_APP_SETTINGS); - emitChange(); - }, []); + setSettings(DEFAULT_APP_SETTINGS); + }, [setSettings]); return { settings, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f4298fc22208..3f8876ec145d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,13 +20,13 @@ import React, { import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; -import { preferredTerminalEditor } from "../terminal-links"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -254,7 +254,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { event.stopPropagation(); const api = readNativeApi(); if (api) { - void api.shell.openInEditor(targetPath, preferredTerminalEditor()); + void openInPreferredEditor(api, targetPath); } else { console.warn("Native API not found. Unable to open file in editor."); } diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e035279ee1fa..39e5cab98b3e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,8 +1,14 @@ -import { type CodexReasoningEffort, type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { + type CodexReasoningEffort, + ProjectId, + type ProviderKind, + type ThreadId, +} from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { getAppModelOptions } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { Schema } from "effect"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; @@ -14,23 +20,7 @@ export const reasoningLabelByOption: Record = { }; const WORKTREE_BRANCH_PREFIX = "t3code"; -export function readLastInvokedScriptByProjectFromStorage(): Record { - const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - if (!stored) return {}; - - try { - const parsed: unknown = JSON.parse(stored); - if (!parsed || typeof parsed !== "object") return {}; - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => - typeof entry[0] === "string" && typeof entry[1] === "string", - ), - ); - } catch { - return {}; - } -} +export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function buildLocalDraftThread( threadId: ThreadId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a32e0a311d01..c70f90fb2b68 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -150,13 +150,14 @@ import { collectUserMessageBlobPreviewUrls, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, - readLastInvokedScriptByProjectFromStorage, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, } from "./ChatView.logic"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -269,9 +270,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< - Record - >(() => readLastInvokedScriptByProjectFromStorage()); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + {}, + LastInvokedScriptByProjectSchema, + ); const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); @@ -634,23 +637,46 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const lastSyncedPendingInputRef = useRef<{ + requestId: string | null; + questionId: string | null; + } | null>(null); useEffect(() => { - if (!activePendingProgress) { + const nextCustomAnswer = activePendingProgress?.customAnswer; + if (typeof nextCustomAnswer !== "string") { + lastSyncedPendingInputRef.current = null; + return; + } + const nextRequestId = activePendingUserInput?.requestId ?? null; + const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; + const questionChanged = + lastSyncedPendingInputRef.current?.requestId !== nextRequestId || + lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; + const textChangedExternally = promptRef.current !== nextCustomAnswer; + + lastSyncedPendingInputRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + }; + + if (!questionChanged && !textChangedExternally) { return; } - promptRef.current = activePendingProgress.customAnswer; - setComposerCursor(activePendingProgress.customAnswer.length); + + promptRef.current = nextCustomAnswer; + setComposerCursor(nextCustomAnswer.length); setComposerTrigger( detectComposerTrigger( - activePendingProgress.customAnswer, - expandCollapsedComposerCursor( - activePendingProgress.customAnswer, - activePendingProgress.customAnswer.length, - ), + nextCustomAnswer, + expandCollapsedComposerCursor(nextCustomAnswer, nextCustomAnswer.length), ), ); setComposerHighlightedItemId(null); - }, [activePendingProgress, activePendingUserInput?.requestId]); + }, [ + activePendingProgress?.customAnswer, + activePendingUserInput?.requestId, + activePendingProgress?.activeQuestion?.id, + ]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -1004,7 +1030,7 @@ export default function ChatView({ threadId }: ChatViewProps) { replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? rest : { ...rest, diff: "1" }; + return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); }, [diffOpen, navigate, threadId]); @@ -1200,6 +1226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError, storeNewTerminal, storeSetActiveTerminal, + setLastInvokedScriptByProjectId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1442,21 +1469,6 @@ export default function ChatView({ threadId }: ChatViewProps) { [serverThread], ); - useEffect(() => { - try { - if (Object.keys(lastInvokedScriptByProjectId).length === 0) { - localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - return; - } - localStorage.setItem( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - JSON.stringify(lastInvokedScriptByProjectId), - ); - } catch { - // Ignore storage write failures (private mode, quota exceeded, etc.) - } - }, [lastInvokedScriptByProjectId]); - // Auto-scroll on new messages const messageCount = timelineMessages.length; const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 96efc0fbf0a4..f9321de65143 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -629,6 +629,7 @@ function ComposerPromptEditorInner({ const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) }); + const isApplyingControlledUpdateRef = useRef(false); useEffect(() => { onChangeRef.current = onChange; @@ -645,21 +646,26 @@ function ComposerPromptEditorInner({ return; } - if (previousSnapshot.value !== value) { - editor.update(() => { - $setComposerEditorPrompt(value); - }); - } - snapshotRef.current = { value, cursor: normalizedCursor }; const rootElement = editor.getRootElement(); - if (!rootElement || document.activeElement !== rootElement) { + const isFocused = Boolean(rootElement && document.activeElement === rootElement); + if (previousSnapshot.value === value && !isFocused) { return; } + isApplyingControlledUpdateRef.current = true; editor.update(() => { - $setSelectionAtComposerOffset(normalizedCursor); + const valueChanged = previousSnapshot.value !== value; + if (previousSnapshot.value !== value) { + $setComposerEditorPrompt(value); + } + if (valueChanged || isFocused) { + $setSelectionAtComposerOffset(normalizedCursor); + } + }); + queueMicrotask(() => { + isApplyingControlledUpdateRef.current = false; }); }, [cursor, editor, value]); @@ -705,9 +711,7 @@ function ComposerPromptEditorInner({ focus: () => { focusAt(snapshotRef.current.cursor); }, - focusAt: (nextCursor: number) => { - focusAt(nextCursor); - }, + focusAt, focusAtEnd: () => { focusAt(snapshotRef.current.value.length); }, @@ -728,6 +732,9 @@ function ComposerPromptEditorInner({ if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) { return; } + if (isApplyingControlledUpdateRef.current) { + return; + } snapshotRef.current = { value: nextValue, cursor: nextCursor, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1293f51f7ed5..b0f1b91c3575 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,11 +12,12 @@ import { useRef, useState, } from "react"; +import { openInPreferredEditor } from "../editorPreferences"; import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; -import { preferredTerminalEditor, resolvePathLinkTarget } from "../terminal-links"; +import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -311,7 +312,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const api = readNativeApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void api.shell.openInEditor(targetPath, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, targetPath).catch((error) => { console.warn("Failed to open diff file in editor.", error); }); }, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f79f01300f51..830a2ec5b77d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -23,6 +23,7 @@ import { summarizeGitResult, } from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; import { Dialog, DialogDescription, @@ -38,6 +39,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; +import { openInPreferredEditor } from "~/editorPreferences"; import { gitBranchesQueryOptions, gitInitMutationOptions, @@ -48,7 +50,7 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; +import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; interface GitActionsControlProps { @@ -66,6 +68,7 @@ interface PendingDefaultBranchAction { commitMessage?: string; forcePushOnlyProgress: boolean; onConfirmed?: () => void; + filePaths?: string[]; } type GitActionToastId = ReturnType; @@ -180,6 +183,8 @@ export default function GitActionsControl({ const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); + const [excludedFiles, setExcludedFiles] = useState>(new Set()); + const [isEditingFiles, setIsEditingFiles] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); @@ -200,6 +205,11 @@ export default function GitActionsControl({ const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; + const allFiles = gitStatusForActions?.workingTree.files ?? []; + const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); + const allSelected = excludedFiles.size === 0; + const noneSelected = selectedFiles.length === 0; + const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); const runImmediateGitActionMutation = useMutation( @@ -284,6 +294,7 @@ export default function GitActionsControl({ featureBranch = false, isDefaultBranchOverride, progressToastId, + filePaths, }: { action: GitStackedAction; commitMessage?: string; @@ -294,6 +305,7 @@ export default function GitActionsControl({ featureBranch?: boolean; isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; + filePaths?: string[]; }) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; @@ -316,6 +328,7 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), }); return; } @@ -365,6 +378,7 @@ export default function GitActionsControl({ action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(filePaths ? { filePaths } : {}), }); try { @@ -466,7 +480,7 @@ export default function GitActionsControl({ const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = + const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ @@ -474,6 +488,7 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, }); }, [pendingDefaultBranchAction, runGitActionWithToast]); @@ -484,6 +499,7 @@ export default function GitActionsControl({ commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; + filePaths?: string[]; }) => { void runGitActionWithToast({ ...actionParams, @@ -496,7 +512,7 @@ export default function GitActionsControl({ const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = + const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); checkoutNewBranchAndRunAction({ @@ -504,6 +520,7 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), }); }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); @@ -513,12 +530,21 @@ export default function GitActionsControl({ setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); + setIsEditingFiles(false); checkoutNewBranchAndRunAction({ action: "commit", ...(commitMessage ? { commitMessage } : {}), + ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), }); - }, [isCommitDialogOpen, dialogCommitMessage, checkoutNewBranchAndRunAction]); + }, [ + allSelected, + isCommitDialogOpen, + dialogCommitMessage, + checkoutNewBranchAndRunAction, + selectedFiles, + ]); const runQuickAction = useCallback(() => { if (quickAction.kind === "open_pr") { @@ -611,6 +637,8 @@ export default function GitActionsControl({ void runGitActionWithToast({ action: "commit_push_pr" }); return; } + setExcludedFiles(new Set()); + setIsEditingFiles(false); setIsCommitDialogOpen(true); }, [openExistingPr, handleSyncFromParent, runGitActionWithToast, setIsCommitDialogOpen], @@ -621,14 +649,19 @@ export default function GitActionsControl({ const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); + setIsEditingFiles(false); void runGitActionWithToast({ action: "commit", ...(commitMessage ? { commitMessage } : {}), + ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), }); }, [ + allSelected, dialogCommitMessage, isCommitDialogOpen, runGitActionWithToast, + selectedFiles, setDialogCommitMessage, setIsCommitDialogOpen, ]); @@ -645,7 +678,7 @@ export default function GitActionsControl({ return; } const target = resolvePathLinkTarget(filePath, gitCwd); - void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, target).catch((error) => { toastManager.add({ type: "error", title: "Unable to open file", @@ -803,6 +836,8 @@ export default function GitActionsControl({ if (!open) { setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); + setIsEditingFiles(false); } }} > @@ -825,37 +860,99 @@ export default function GitActionsControl({
-

Files

- {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? ( +
+
+ {isEditingFiles && allFiles.length > 0 && ( + { + setExcludedFiles( + allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(), + ); + }} + /> + )} + Files + {!allSelected && !isEditingFiles && ( + + ({selectedFiles.length} of {allFiles.length}) + + )} +
+ {allFiles.length > 0 && ( + + )} +
+ {!gitStatusForActions || allFiles.length === 0 ? (

none

) : (
- {gitStatusForActions.workingTree.files.map((file) => ( - - ))} + {allFiles.map((file) => { + const isExcluded = excludedFiles.has(file.path); + return ( +
+ {isEditingFiles && ( + { + setExcludedFiles((prev) => { + const next = new Set(prev); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + /> + )} + +
+ ); + })}
- +{gitStatusForActions.workingTree.insertions} + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - -{gitStatusForActions.workingTree.deletions} + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
@@ -879,14 +976,21 @@ export default function GitActionsControl({ onClick={() => { setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); + setIsEditingFiles(false); }} > Cancel - - diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d173946f5ccb..95288b813169 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1233,7 +1233,7 @@ export default function Sidebar() { +
Code diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index cce2ed133824..7861212e4869 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -12,10 +12,10 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { openInPreferredEditor } from "../editorPreferences"; import { extractTerminalLinks, isTerminalLinkActivation, - preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; @@ -236,7 +236,7 @@ function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts new file mode 100644 index 000000000000..7074f4601983 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { computeMessageDurationStart } from "./MessagesTimeline.logic"; + +describe("computeMessageDurationStart", () => { + it("returns message createdAt when there is no preceding user message", () => { + const result = computeMessageDurationStart([ + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:05Z", + completedAt: "2026-01-01T00:00:10Z", + }, + ]); + expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); + }); + + it("uses the user message createdAt for the first assistant response", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("uses the previous assistant completedAt for subsequent assistant responses", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:30Z"], + ]), + ); + }); + + it("does not advance the boundary for a streaming message without completedAt", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:00:55Z", + completedAt: "2026-01-01T00:00:55Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["a2", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("resets the boundary on a new user message", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, + { + id: "a2", + role: "assistant", + createdAt: "2026-01-01T00:01:20Z", + completedAt: "2026-01-01T00:01:20Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ["u2", "2026-01-01T00:01:00Z"], + ["a2", "2026-01-01T00:01:00Z"], + ]), + ); + }); + + it("handles system messages without affecting the boundary", () => { + const result = computeMessageDurationStart([ + { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + completedAt: "2026-01-01T00:00:30Z", + }, + ]); + + expect(result).toEqual( + new Map([ + ["u1", "2026-01-01T00:00:00Z"], + ["s1", "2026-01-01T00:00:00Z"], + ["a1", "2026-01-01T00:00:00Z"], + ]), + ); + }); + + it("returns empty map for empty input", () => { + expect(computeMessageDurationStart([])).toEqual(new Map()); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts new file mode 100644 index 000000000000..45408468caec --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -0,0 +1,25 @@ +export interface TimelineDurationMessage { + id: string; + role: "user" | "assistant" | "system"; + createdAt: string; + completedAt?: string | undefined; +} + +export function computeMessageDurationStart( + messages: ReadonlyArray, +): Map { + const result = new Map(); + let lastBoundary: string | null = null; + + for (const message of messages) { + if (message.role === "user") { + lastBoundary = message.createdAt; + } + result.set(message.id, lastBoundary ?? message.createdAt); + if (message.role === "assistant" && message.completedAt) { + lastBoundary = message.completedAt; + } + } + + return result; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 24f2ae3c5841..81feb5f263c4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -33,6 +33,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; +import { computeMessageDurationStart } from "./MessagesTimeline.logic"; const TOOL_ICON_CLASS = "h-3.5 w-3.5 shrink-0 text-muted-foreground/50"; @@ -192,6 +193,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const rows = useMemo(() => { const nextRows: TimelineRow[] = []; + const durationStartByMessageId = computeMessageDurationStart( + timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), + ); for (let index = 0; index < timelineEntries.length; index += 1) { const timelineEntry = timelineEntries[index]; @@ -233,6 +237,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ id: timelineEntry.id, createdAt: timelineEntry.createdAt, message: timelineEntry.message, + durationStart: + durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt, showCompletionDivider: timelineEntry.message.role === "assistant" && completionDividerBeforeEntryId === timelineEntry.id, @@ -625,8 +631,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {formatMessageMeta( row.message.createdAt, row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), )}

@@ -723,6 +729,7 @@ type TimelineRow = id: string; createdAt: string; message: TimelineMessage; + durationStart: string; showCompletionDivider: boolean; } | { diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 701204c1be93..2e09da03c19b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,6 +1,7 @@ -import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; +import { usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; @@ -9,7 +10,35 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -const LAST_EDITOR_KEY = "t3code:last-editor"; +const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { + const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: isMacPlatform(platform) + ? "Finder" + : isWindowsPlatform(platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ]; + return baseOptions.filter((option) => availableEditors.includes(option.value)); +}; export const OpenInPicker = memo(function OpenInPicker({ keybindings, @@ -20,61 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; }) { - const [lastEditor, setLastEditor] = useState(() => { - const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; - }); - - const allOptions = useMemo>( - () => [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - }, - { - label: isMacPlatform(navigator.platform) - ? "Finder" - : isWindowsPlatform(navigator.platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - }, - ], - [], - ); + const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( - () => allOptions.filter((option) => availableEditors.includes(option.value)), - [allOptions, availableEditors], + () => resolveOptions(navigator.platform, availableEditors), + [availableEditors], ); - - const effectiveEditor = options.some((option) => option.value === lastEditor) - ? lastEditor - : (options[0]?.value ?? null); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); if (!api || !openInCwd) return; - const editor = editorId ?? effectiveEditor; + const editor = editorId ?? preferredEditor; if (!editor) return; void api.shell.openInEditor(openInCwd, editor); - localStorage.setItem(LAST_EDITOR_KEY, editor); - setLastEditor(editor); + setPreferredEditor(editor); }, - [effectiveEditor, openInCwd, setLastEditor], + [preferredEditor, openInCwd, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -87,22 +78,22 @@ export const OpenInPicker = memo(function OpenInPicker({ const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !openInCwd) return; - if (!effectiveEditor) return; + if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); + void api.shell.openInEditor(openInCwd, preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); + }, [preferredEditor, keybindings, openInCwd]); return (