From 15d484980dda3628c583eeb87bb641e4b97cfe5d Mon Sep 17 00:00:00 2001 From: Harshvardhan Vatsa <76729417+hrsvrn@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:26:04 +0530 Subject: [PATCH] fix(x): show chat timestamps in the user's local timezone (#779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(x): show chat timestamps in the user's local timezone The per-message "Current date and time" context was built with toISOString(), giving the model a UTC time frame with no zone. It then quoted email Date: headers (sent in +0000) in UTC — an email shown as 1:06 PM IST in the inbox was described as 7:36 AM in chat. Use local wall-clock via toLocaleString with an explicit timeZoneName and the IANA zone (matching what the legacy engine already did) so the model converts offset-bearing timestamps to local time. * fix(x): convert email timestamps to local time for the assistant The "now" fix gave the model a local time frame, but email dates still reached it as raw RFC 2822 Date: headers (marketing mail is sent in +0000), so it kept quoting them in UTC — a 1:06 PM IST email described as 7:36 AM in chat. Add formatEmailDateLocal() and apply it at the three model-facing serialization points: the read-view email tool, the thread-summary line, and the gmail-sync knowledge markdown (**Date:**). The structured date field on snapshots/messages is left raw, since the renderer parses it with new Date() and formats it client-side. * fix(x): generalize local-time formatting for model-facing timestamps Move formatEmailDateLocal out of sync_gmail into a shared formatTimestampForModel() util — email is just the first source of external timestamps; Slack/calendar serialization can now reuse the same one-line call instead of growing per-source formatters. Also add a system-prompt instruction to always express times in the user's local timezone, as a fallback for raw dates inside email bodies, web content, and third-party tool output that pre-conversion can't reach. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/x/apps/renderer/src/App.tsx | 13 ++++++- .../packages/core/src/knowledge/sync_gmail.ts | 5 +-- .../compose-instructions.test.ts.snap | 24 ++++++------- .../runtime/assembly/compose-instructions.ts | 2 +- .../core/src/runtime/tools/domains/app.ts | 5 ++- apps/x/packages/shared/src/index.ts | 1 + apps/x/packages/shared/src/time.ts | 34 +++++++++++++++++++ 7 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 apps/x/packages/shared/src/time.ts diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 711c5c27..57fd36b5 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3054,7 +3054,18 @@ function App() { ...(chatMaxModelCalls !== undefined ? { maxModelCalls: chatMaxModelCalls } : {}), } const userMessageContextFor = (middlePane: Awaited>) => ({ - currentDateTime: new Date().toISOString(), + // Local wall-clock with explicit timezone, never toISOString: the model + // adopts this as its time frame, so a UTC "now" makes it quote email + // timestamps (which carry their own offsets) in UTC instead of local. + currentDateTime: `${new Date().toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + })} (${Intl.DateTimeFormat().resolvedOptions().timeZone})`, middlePane: middlePane ?? { kind: 'empty' as const }, }) diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index e4dd956b..2de211e5 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -13,6 +13,7 @@ import { classifyThread, getUserEmail, type EmailCategory } from './classify_thr import { recordImportanceCorrection } from './email_importance_feedback.js'; import { recordCategoryCorrection } from './email_category_feedback.js'; import { notifyIfEnabled } from '../application/notification/notifier.js'; +import { formatTimestampForModel } from '@x/shared/dist/time.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); @@ -1157,7 +1158,7 @@ async function parseThreadSnapshot( const earlier = visibleMessages.slice(0, -1); const earlierSummary = earlier .map((msg) => { - const date = msg.date ? ` (${msg.date})` : ''; + const date = msg.date ? ` (${formatTimestampForModel(msg.date)})` : ''; const body = msg.body.replace(/\s+/g, ' ').slice(0, 500).trim(); return `${msg.from}${date}: ${body}`; }) @@ -1330,7 +1331,7 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri const date = headers.find(h => h.name === 'Date')?.value || 'Unknown'; mdContent += `### From: ${from}\n`; - mdContent += `**Date:** ${date}\n\n`; + mdContent += `**Date:** ${formatTimestampForModel(date)}\n\n`; if (msg.payload) { const body = getBody(msg.payload); diff --git a/apps/x/packages/core/src/runtime/assembly/__snapshots__/compose-instructions.test.ts.snap b/apps/x/packages/core/src/runtime/assembly/__snapshots__/compose-instructions.test.ts.snap index 4a3dadc5..3099a208 100644 --- a/apps/x/packages/core/src/runtime/assembly/__snapshots__/compose-instructions.test.ts.snap +++ b/apps/x/packages/core/src/runtime/assembly/__snapshots__/compose-instructions.test.ts.snap @@ -6,7 +6,7 @@ exports[`composeSystemInstructions golden bytes > agent notes 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -27,7 +27,7 @@ exports[`composeSystemInstructions golden bytes > base: no modes active 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -44,7 +44,7 @@ exports[`composeSystemInstructions golden bytes > coach mode 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -72,7 +72,7 @@ exports[`composeSystemInstructions golden bytes > code mode claude with cwd 1`] # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -105,7 +105,7 @@ exports[`composeSystemInstructions golden bytes > code mode codex without cwd 1` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -138,7 +138,7 @@ exports[`composeSystemInstructions golden bytes > everything on 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -285,7 +285,7 @@ exports[`composeSystemInstructions golden bytes > search enabled 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -305,7 +305,7 @@ exports[`composeSystemInstructions golden bytes > video mode 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -346,7 +346,7 @@ exports[`composeSystemInstructions golden bytes > voice input 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -368,7 +368,7 @@ exports[`composeSystemInstructions golden bytes > voice output full 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -430,7 +430,7 @@ exports[`composeSystemInstructions golden bytes > voice output summary 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. @@ -495,7 +495,7 @@ exports[`composeSystemInstructions golden bytes > work directory 1`] = ` # Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. diff --git a/apps/x/packages/core/src/runtime/assembly/compose-instructions.ts b/apps/x/packages/core/src/runtime/assembly/compose-instructions.ts index 2bfe37b0..89281177 100644 --- a/apps/x/packages/core/src/runtime/assembly/compose-instructions.ts +++ b/apps/x/packages/core/src/runtime/assembly/compose-instructions.ts @@ -25,7 +25,7 @@ const PROMPT_CAPABILITIES = [ const USER_CONTEXT_SYSTEM_INSTRUCTIONS = `# Hidden User Context User messages may include a hidden "# User Context" section before "# User Message". Treat it as runtime metadata captured when that specific user message was sent. The actual user-authored text starts under "# User Message". -Use "Current date and time" for temporal reasoning. +Use "Current date and time" for temporal reasoning; it reflects the user's local timezone. Always express dates and times in that local timezone: timestamps inside emails, web content, or tool output may carry other offsets (often UTC) — convert those to local time before repeating them. If Middle pane context is present, it reflects what the user had open at the time of that specific message and overrides earlier middle-pane references. If the conversation history references a different note or browser page, the user had since closed or navigated away from it. Do not treat earlier context as current. diff --git a/apps/x/packages/core/src/runtime/tools/domains/app.ts b/apps/x/packages/core/src/runtime/tools/domains/app.ts index 4af10d1c..c91abf8b 100644 --- a/apps/x/packages/core/src/runtime/tools/domains/app.ts +++ b/apps/x/packages/core/src/runtime/tools/domains/app.ts @@ -11,6 +11,7 @@ import { WorkDir } from "../../../config/config.js"; import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.js"; import { listApps } from "../../../apps/indexer.js"; import { listImportantThreads, searchThreads } from "../../../knowledge/sync_gmail.js"; +import { formatTimestampForModel } from "@x/shared/dist/time.js"; import { listTasks as listBackgroundTasks } from "../../../background-tasks/fileops.js"; import type { ISessions } from "../../sessions/api.js"; import { BuiltinToolsSchema } from "../types.js"; @@ -113,7 +114,9 @@ export const appNavigationTools: z.infer = { threadId: t.threadId, subject: t.subject ?? '(no subject)', from: t.from ?? '', - date: t.date ?? '', + // Local-timezone string for the model, not the raw + // header — otherwise it quotes email times in UTC. + date: formatTimestampForModel(t.date), unread: t.unread ?? false, summary: t.summary ? t.summary.slice(0, 200) : undefined, })); diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 6661420e..6841e705 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -22,5 +22,6 @@ export * as notificationSettings from './notification-settings.js'; export * as turnLimits from './turn-limits.js'; export * as codeSessions from './code-sessions.js'; export * as channels from './channels.js'; +export * as time from './time.js'; export * as rowboatApp from './rowboat-app.js'; export { PrefixLogger }; diff --git a/apps/x/packages/shared/src/time.ts b/apps/x/packages/shared/src/time.ts new file mode 100644 index 00000000..691fc7d2 --- /dev/null +++ b/apps/x/packages/shared/src/time.ts @@ -0,0 +1,34 @@ +/** + * Format a timestamp into the user's local timezone for any text shown to the + * LLM (tool output, knowledge markdown, thread summaries). External sources + * carry their own offsets — email `Date:` headers are typically `+0000`, Slack + * uses epoch seconds, calendar APIs return UTC ISO strings. Handed to the model + * raw, it quotes them verbatim instead of converting — e.g. a 1:06 PM IST email + * described as "7:36 AM" in chat. Call this at every model-facing serialization + * boundary; the model's tendency to quote timestamps verbatim then produces + * correct output. The weekday is included because models are unreliable at + * day-of-week arithmetic. Uses the system timezone of the process it runs in. + * + * NOT for structured `date` fields that code later parses with `new Date(...)` + * — those must stay raw parseable values. + * + * Accepts a raw date string, epoch milliseconds, or a Date. Unparseable + * strings (e.g. 'Unknown') are returned unchanged; null/undefined and invalid + * Date/number inputs return ''. + */ +export function formatTimestampForModel(raw: string | number | Date | undefined | null): string { + if (raw === undefined || raw === null || raw === '') return ''; + const parsed = raw instanceof Date ? raw : new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return typeof raw === 'string' ? raw : ''; + } + return parsed.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }); +}