| author | |
| committer | |
| log | 2daff8c25adf701fddd062ae93b94cc57d420ec2 |
| tree | 19827171d8e6b927957872545afe44880480732c |
| parent | 8dcb96314c976899e4df6951fb9af03131c2a46f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com>13 files changed, 3 insertions(+), 282 deletions(-)
apps/web/src/appearanceFonts.test.ts-15| ... | ... | @@ -5,9 +5,6 @@ import { |
| 5 | 5 | clampCodeFontSize, |
| 6 | 6 | clampInterfaceFontSize, |
| 7 | 7 | clampPromptFontSize, |
| 8 | DEFAULT_CODE_FONT_STACK, | |
| 9 | DEFAULT_SANS_FONT_STACK, | |
| 10 | appearanceFontStack, | |
| 11 | 8 | cssFontFamilies, |
| 12 | 9 | resolveDefaultFamilyLabel, |
| 13 | 10 | resolveTerminalFontPreference, |
| ... | ... | @@ -58,18 +55,6 @@ describe("resolveDefaultFamilyLabel", () => { |
| 58 | 55 | }); |
| 59 | 56 | }); |
| 60 | 57 | |
| 61 | describe("appearanceFontStack", () => { | |
| 62 | it("prepends the custom family to the default stack", () => { | |
| 63 | expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( | |
| 64 | `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, | |
| 65 | ); | |
| 66 | }); | |
| 67 | ||
| 68 | it("falls back to the default stack when unset", () => { | |
| 69 | expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | 58 | describe("resolveTerminalFontPreference", () => { |
| 74 | 59 | it("inherits the code font in simple mode", () => { |
| 75 | 60 | expect( |
apps/web/src/appearanceFonts.ts-6| ... | ... | @@ -71,12 +71,6 @@ export function cssFontFamilies(input: string): string | null { |
| 71 | 71 | return families.length > 0 ? families.join(", ") : null; |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | /** The full stack a preference resolves to: custom families before the default. */ | |
| 75 | export function appearanceFontStack(custom: string, defaultStack: string): string { | |
| 76 | const families = cssFontFamilies(custom); | |
| 77 | return families === null ? defaultStack : `${families}, ${defaultStack}`; | |
| 78 | } | |
| 79 | ||
| 80 | 74 | export interface AppearanceFontPreferences { |
| 81 | 75 | readonly sans: string; |
| 82 | 76 | readonly code: string; |
apps/web/src/components/Sidebar.logic.test.ts-39| ... | ... | @@ -20,7 +20,6 @@ import { |
| 20 | 20 | isTrailingDoubleClick, |
| 21 | 21 | orderItemsByPreferredIds, |
| 22 | 22 | resolveProjectStatusIndicator, |
| 23 | resolveSidebarStageBadgeLabel, | |
| 24 | 23 | resolveThreadRowClassName, |
| 25 | 24 | resolveSidebarThreadStatus, |
| 26 | 25 | resolveThreadStatusPill, |
| ... | ... | @@ -236,44 +235,6 @@ describe("buildMultiSelectThreadContextMenuItems", () => { |
| 236 | 235 | }); |
| 237 | 236 | }); |
| 238 | 237 | |
| 239 | describe("resolveSidebarStageBadgeLabel", () => { | |
| 240 | it("returns Nightly for nightly primary server versions", () => { | |
| 241 | expect( | |
| 242 | resolveSidebarStageBadgeLabel({ | |
| 243 | primaryServerVersion: "0.0.28-nightly.20260616.12", | |
| 244 | fallbackStageLabel: "Alpha", | |
| 245 | }), | |
| 246 | ).toBe("Nightly"); | |
| 247 | }); | |
| 248 | ||
| 249 | it("returns the fallback label for stable primary server versions", () => { | |
| 250 | expect( | |
| 251 | resolveSidebarStageBadgeLabel({ | |
| 252 | primaryServerVersion: "0.0.27", | |
| 253 | fallbackStageLabel: "Alpha", | |
| 254 | }), | |
| 255 | ).toBe("Alpha"); | |
| 256 | }); | |
| 257 | ||
| 258 | it("returns the fallback label when the primary server version is missing", () => { | |
| 259 | expect( | |
| 260 | resolveSidebarStageBadgeLabel({ | |
| 261 | primaryServerVersion: null, | |
| 262 | fallbackStageLabel: "Dev", | |
| 263 | }), | |
| 264 | ).toBe("Dev"); | |
| 265 | }); | |
| 266 | ||
| 267 | it("returns the fallback label for malformed nightly prerelease versions", () => { | |
| 268 | expect( | |
| 269 | resolveSidebarStageBadgeLabel({ | |
| 270 | primaryServerVersion: "0.0.28-nightly.20260616", | |
| 271 | fallbackStageLabel: "Alpha", | |
| 272 | }), | |
| 273 | ).toBe("Alpha"); | |
| 274 | }); | |
| 275 | }); | |
| 276 | ||
| 277 | 238 | function makeLatestTurn(overrides?: { |
| 278 | 239 | completedAt?: string | null; |
| 279 | 240 | startedAt?: string | null; |
apps/web/src/components/Sidebar.logic.ts-8| ... | ... | @@ -13,7 +13,6 @@ import type { SidebarThreadSummary, Thread } from "../types"; |
| 13 | 13 | import type { ThreadRouteTarget } from "../threadRoutes"; |
| 14 | 14 | import { cn } from "../lib/utils"; |
| 15 | 15 | import { isLatestTurnSettled } from "../session-logic"; |
| 16 | import { resolveServerBackedAppStageLabel } from "../branding.logic"; | |
| 17 | 16 | |
| 18 | 17 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 19 | 18 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; |
| ... | ... | @@ -170,13 +169,6 @@ export interface ThreadJumpHintVisibilityController { |
| 170 | 169 | dispose: () => void; |
| 171 | 170 | } |
| 172 | 171 | |
| 173 | export function resolveSidebarStageBadgeLabel(input: { | |
| 174 | primaryServerVersion: string | null | undefined; | |
| 175 | fallbackStageLabel: string; | |
| 176 | }): string { | |
| 177 | return resolveServerBackedAppStageLabel(input); | |
| 178 | } | |
| 179 | ||
| 180 | 172 | export function createThreadJumpHintVisibilityController(input: { |
| 181 | 173 | delayMs: number; |
| 182 | 174 | onVisibilityChange: (visible: boolean) => void; |
apps/web/src/pendingUserInput.test.ts-24| ... | ... | @@ -4,7 +4,6 @@ import { |
| 4 | 4 | buildPendingUserInputAnswers, |
| 5 | 5 | countAnsweredPendingUserInputQuestions, |
| 6 | 6 | derivePendingUserInputProgress, |
| 7 | findFirstUnansweredPendingUserInputQuestionIndex, | |
| 8 | 7 | resolvePendingUserInputAnswer, |
| 9 | 8 | setPendingUserInputCustomAnswer, |
| 10 | 9 | togglePendingUserInputOptionSelection, |
| ... | ... | @@ -182,29 +181,6 @@ describe("pending user input question progress", () => { |
| 182 | 181 | ).toBe(1); |
| 183 | 182 | }); |
| 184 | 183 | |
| 185 | it("finds the first unanswered question", () => { | |
| 186 | expect( | |
| 187 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { | |
| 188 | scope: { | |
| 189 | selectedOptionLabels: ["Orchestration-first"], | |
| 190 | }, | |
| 191 | }), | |
| 192 | ).toBe(1); | |
| 193 | }); | |
| 194 | ||
| 195 | it("returns the last question index when all answers are complete", () => { | |
| 196 | expect( | |
| 197 | findFirstUnansweredPendingUserInputQuestionIndex(questions, { | |
| 198 | scope: { | |
| 199 | selectedOptionLabels: ["Orchestration-first"], | |
| 200 | }, | |
| 201 | compat: { | |
| 202 | customAnswer: "Keep it for one release window", | |
| 203 | }, | |
| 204 | }), | |
| 205 | ).toBe(1); | |
| 206 | }); | |
| 207 | ||
| 208 | 184 | it("derives the active question and advancement state", () => { |
| 209 | 185 | expect( |
| 210 | 186 | derivePendingUserInputProgress( |
apps/web/src/pendingUserInput.ts-11| ... | ... | @@ -128,17 +128,6 @@ export function countAnsweredPendingUserInputQuestions( |
| 128 | 128 | }, 0); |
| 129 | 129 | } |
| 130 | 130 | |
| 131 | export function findFirstUnansweredPendingUserInputQuestionIndex( | |
| 132 | questions: ReadonlyArray<UserInputQuestion>, | |
| 133 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | |
| 134 | ): number { | |
| 135 | const unansweredIndex = questions.findIndex( | |
| 136 | (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), | |
| 137 | ); | |
| 138 | ||
| 139 | return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; | |
| 140 | } | |
| 141 | ||
| 142 | 131 | export function derivePendingUserInputProgress( |
| 143 | 132 | questions: ReadonlyArray<UserInputQuestion>, |
| 144 | 133 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
apps/web/src/providerUpdateDismissal.test.ts deleted-23| ... | ... | @@ -1,23 +0,0 @@ |
| 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { removeLocalStorageItem } from "./hooks/useLocalStorage"; | |
| 4 | import { | |
| 5 | dismissProviderUpdateNotification, | |
| 6 | isProviderUpdateNotificationDismissed, | |
| 7 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | |
| 8 | } from "./providerUpdateDismissal"; | |
| 9 | ||
| 10 | describe("providerUpdateDismissal", () => { | |
| 11 | beforeEach(() => { | |
| 12 | removeLocalStorageItem(PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY); | |
| 13 | }); | |
| 14 | ||
| 15 | it("persists provider update dismissals by notification key", () => { | |
| 16 | expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(false); | |
| 17 | ||
| 18 | dismissProviderUpdateNotification("opencode:1.14.33"); | |
| 19 | ||
| 20 | expect(isProviderUpdateNotificationDismissed("opencode:1.14.33")).toBe(true); | |
| 21 | expect(isProviderUpdateNotificationDismissed("opencode:1.14.34")).toBe(false); | |
| 22 | }); | |
| 23 | }); |
apps/web/src/providerUpdateDismissal.ts+2-55| ... | ... | @@ -1,67 +1,14 @@ |
| 1 | 1 | import { useCallback, useMemo } from "react"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; | |
| 4 | import { useLocalStorage } from "./hooks/useLocalStorage"; | |
| 5 | 5 | |
| 6 | export const PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY = "t3code:provider-update-dismissals:v1"; | |
| 6 | const PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY = "t3code:provider-update-dismissals:v1"; | |
| 7 | 7 | |
| 8 | 8 | const ProviderUpdateDismissalsSchema = Schema.Struct({ |
| 9 | 9 | keys: Schema.Array(Schema.String), |
| 10 | 10 | }); |
| 11 | 11 | |
| 12 | type ProviderUpdateDismissals = typeof ProviderUpdateDismissalsSchema.Type; | |
| 13 | ||
| 14 | function readProviderUpdateDismissals(): ProviderUpdateDismissals { | |
| 15 | try { | |
| 16 | return ( | |
| 17 | getLocalStorageItem( | |
| 18 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | |
| 19 | ProviderUpdateDismissalsSchema, | |
| 20 | ) ?? { | |
| 21 | keys: [], | |
| 22 | } | |
| 23 | ); | |
| 24 | } catch (error) { | |
| 25 | console.error("Could not read provider-update dismissals.", error); | |
| 26 | return { keys: [] }; | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | function writeProviderUpdateDismissals(document: ProviderUpdateDismissals): void { | |
| 31 | try { | |
| 32 | setLocalStorageItem( | |
| 33 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, | |
| 34 | document, | |
| 35 | ProviderUpdateDismissalsSchema, | |
| 36 | ); | |
| 37 | } catch (error) { | |
| 38 | console.error("Could not persist provider-update dismissals.", error); | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export function isProviderUpdateNotificationDismissed( | |
| 43 | dismissalKey: string | null | undefined, | |
| 44 | ): boolean { | |
| 45 | if (!dismissalKey) { | |
| 46 | return false; | |
| 47 | } | |
| 48 | return readProviderUpdateDismissals().keys.includes(dismissalKey); | |
| 49 | } | |
| 50 | ||
| 51 | export function dismissProviderUpdateNotification(dismissalKey: string | null | undefined): void { | |
| 52 | const trimmedKey = dismissalKey?.trim(); | |
| 53 | if (!trimmedKey) { | |
| 54 | return; | |
| 55 | } | |
| 56 | const document = readProviderUpdateDismissals(); | |
| 57 | if (document.keys.includes(trimmedKey)) { | |
| 58 | return; | |
| 59 | } | |
| 60 | writeProviderUpdateDismissals({ | |
| 61 | keys: [...document.keys, trimmedKey], | |
| 62 | }); | |
| 63 | } | |
| 64 | ||
| 65 | 12 | export function useDismissedProviderUpdateNotificationKeys() { |
| 66 | 13 | const [dismissals, setDismissals] = useLocalStorage( |
| 67 | 14 | PROVIDER_UPDATE_DISMISSALS_STORAGE_KEY, |
apps/web/src/threadSync.test.ts+1-8| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; | |
| 3 | import { resolveThreadSyncPhase } from "./threadSync"; | |
| 4 | 4 | |
| 5 | 5 | describe("resolveThreadSyncPhase", () => { |
| 6 | 6 | it("loads when only shell data is available", () => { |
| ... | ... | @@ -40,10 +40,3 @@ describe("resolveThreadSyncPhase", () => { |
| 40 | 40 | ).toBeNull(); |
| 41 | 41 | }); |
| 42 | 42 | }); |
| 43 | ||
| 44 | describe("threadSyncLabel", () => { | |
| 45 | it("uses the same loading and syncing language as mobile", () => { | |
| 46 | expect(threadSyncLabel("loading")).toBe("Loading messages..."); | |
| 47 | expect(threadSyncLabel("syncing")).toBe("Syncing messages..."); | |
| 48 | }); | |
| 49 | }); |
apps/web/src/timestampFormat.test.ts-39| ... | ... | @@ -6,10 +6,7 @@ import { |
| 6 | 6 | formatExpiresInLabel, |
| 7 | 7 | formatRelativeTime, |
| 8 | 8 | formatRelativeTimeLabel, |
| 9 | formatRelativeTimeUntil, | |
| 10 | formatRelativeTimeUntilLabel, | |
| 11 | 9 | formatShortTimestamp, |
| 12 | formatTimestamp, | |
| 13 | 10 | getRelativeTimeState, |
| 14 | 11 | getTimestampFormatOptions, |
| 15 | 12 | resolveTimestampLocale, |
| ... | ... | @@ -76,33 +73,6 @@ describe("resolveTimestampLocale", () => { |
| 76 | 73 | }); |
| 77 | 74 | }); |
| 78 | 75 | |
| 79 | describe("formatRelativeTimeUntilLabel", () => { | |
| 80 | beforeEach(() => { | |
| 81 | vi.useFakeTimers(); | |
| 82 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); | |
| 83 | }); | |
| 84 | ||
| 85 | afterEach(() => { | |
| 86 | vi.useRealTimers(); | |
| 87 | }); | |
| 88 | ||
| 89 | it("returns Expired when the instant is in the past", () => { | |
| 90 | expect(formatRelativeTimeUntilLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); | |
| 91 | }); | |
| 92 | ||
| 93 | it("formats seconds remaining", () => { | |
| 94 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:45.000Z")).toBe("45s left"); | |
| 95 | }); | |
| 96 | ||
| 97 | it("formats minutes remaining", () => { | |
| 98 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:15:00.000Z")).toBe("15m left"); | |
| 99 | }); | |
| 100 | ||
| 101 | it("formats hours remaining", () => { | |
| 102 | expect(formatRelativeTimeUntilLabel("2026-04-07T18:00:00.000Z")).toBe("6h left"); | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | 76 | describe("formatExpiresInLabel", () => { |
| 107 | 77 | beforeEach(() => { |
| 108 | 78 | vi.useFakeTimers(); |
| ... | ... | @@ -196,10 +166,6 @@ describe("formatDayAwareTimestamp", () => { |
| 196 | 166 | }); |
| 197 | 167 | |
| 198 | 168 | describe("invalid timestamp inputs", () => { |
| 199 | it("returns an empty timestamp instead of throwing", () => { | |
| 200 | expect(formatTimestamp("not-a-date", "12-hour")).toBe(""); | |
| 201 | }); | |
| 202 | ||
| 203 | 169 | it("returns an empty short timestamp instead of throwing", () => { |
| 204 | 170 | expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); |
| 205 | 171 | }); |
| ... | ... | @@ -218,11 +184,6 @@ describe("invalid timestamp inputs", () => { |
| 218 | 184 | expect(formatElapsedDurationLabel("not-a-date")).toBe(""); |
| 219 | 185 | }); |
| 220 | 186 | |
| 221 | it("returns an empty relative time until label instead of a NaN label", () => { | |
| 222 | expect(formatRelativeTimeUntil("not-a-date")).toBeNull(); | |
| 223 | expect(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); | |
| 224 | }); | |
| 225 | ||
| 226 | 187 | it("returns an empty expires-in label instead of a NaN label", () => { |
| 227 | 188 | expect(formatExpiresInLabel("not-a-date")).toBe(""); |
| 228 | 189 | }); |
apps/web/src/timestampFormat.ts-31| ... | ... | @@ -78,12 +78,6 @@ export function parseTimestampDate(isoDate: string): Date | null { |
| 78 | 78 | return Number.isNaN(date.getTime()) ? null : date; |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { | |
| 82 | const date = parseTimestampDate(isoDate); | |
| 83 | if (!date) return ""; | |
| 84 | return getTimestampFormatter(timestampFormat, true).format(date); | |
| 85 | } | |
| 86 | ||
| 87 | 81 | // Deliberately not the host locale: the tooltip's ordinal suffix and |
| 88 | 82 | // day-before-month order below are English, so a localized month alone would |
| 89 | 83 | // read "4th Juni 2026". Localizing the whole label is a separate change. |
| ... | ... | @@ -228,31 +222,6 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date |
| 228 | 222 | return `${days}d`; |
| 229 | 223 | } |
| 230 | 224 | |
| 231 | /** | |
| 232 | * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. | |
| 233 | */ | |
| 234 | export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { | |
| 235 | const date = parseTimestampDate(isoDate); | |
| 236 | if (!date) return null; | |
| 237 | const diffMs = date.getTime() - Date.now(); | |
| 238 | if (diffMs <= 0) return { value: "Expired", suffix: null }; | |
| 239 | const seconds = Math.floor(diffMs / 1000); | |
| 240 | if (seconds < 5) return { value: "Soon", suffix: null }; | |
| 241 | if (seconds < 60) return { value: `${seconds}s`, suffix: "left" }; | |
| 242 | const minutes = Math.floor(seconds / 60); | |
| 243 | if (minutes < 60) return { value: `${minutes}m`, suffix: "left" }; | |
| 244 | const hours = Math.floor(minutes / 60); | |
| 245 | if (hours < 24) return { value: `${hours}h`, suffix: "left" }; | |
| 246 | const days = Math.floor(hours / 24); | |
| 247 | return { value: `${days}d`, suffix: "left" }; | |
| 248 | } | |
| 249 | ||
| 250 | export function formatRelativeTimeUntilLabel(isoDate: string): string { | |
| 251 | const relative = formatRelativeTimeUntil(isoDate); | |
| 252 | if (!relative) return ""; | |
| 253 | return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; | |
| 254 | } | |
| 255 | ||
| 256 | 225 | /** |
| 257 | 226 | * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. |
| 258 | 227 | * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. |
apps/web/src/versionSkew.test.ts-9| ... | ... | @@ -8,7 +8,6 @@ vi.mock("./branding", () => branding); |
| 8 | 8 | |
| 9 | 9 | import { APP_VERSION } from "./branding"; |
| 10 | 10 | import { |
| 11 | appendVersionMismatchHint, | |
| 12 | 11 | buildVersionMismatchDismissalKey, |
| 13 | 12 | dismissVersionMismatch, |
| 14 | 13 | isVersionMismatchDismissed, |
| ... | ... | @@ -139,14 +138,6 @@ describe("versionSkew", () => { |
| 139 | 138 | ).toBe(false); |
| 140 | 139 | }); |
| 141 | 140 | |
| 142 | it("appends a hint to connection errors when the server is behind", () => { | |
| 143 | const mismatch = resolveVersionMismatch("0.0.33"); | |
| 144 | ||
| 145 | expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe( | |
| 146 | `Socket closed. Hint: ${MISMATCH_HINT}`, | |
| 147 | ); | |
| 148 | }); | |
| 149 | ||
| 150 | 141 | it("reads desktop-managed update capabilities from config descriptors", () => { |
| 151 | 142 | expect( |
| 152 | 143 | resolveServerSelfUpdateCapability({ |
apps/web/src/versionSkew.ts-14| ... | ... | @@ -159,17 +159,3 @@ export function dismissVersionMismatch(dismissalKey: string | null | undefined): |
| 159 | 159 | keys: [...document.keys, dismissalKey], |
| 160 | 160 | }); |
| 161 | 161 | } |
| 162 | ||
| 163 | export function appendVersionMismatchHint( | |
| 164 | message: string | null | undefined, | |
| 165 | mismatch: VersionMismatch | null | undefined, | |
| 166 | ): string | null { | |
| 167 | const normalizedMessage = normalizeVersion(message); | |
| 168 | if (!normalizedMessage) { | |
| 169 | return mismatch?.hint ?? null; | |
| 170 | } | |
| 171 | if (!mismatch) { | |
| 172 | return normalizedMessage; | |
| 173 | } | |
| 174 | return `${normalizedMessage} Hint: ${mismatch.hint}`; | |
| 175 | } |