diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts
index a384557310..967c454e2d 100644
--- a/packages/ui/src/features/command/keyboard-shortcuts.ts
+++ b/packages/ui/src/features/command/keyboard-shortcuts.ts
@@ -27,6 +27,9 @@ export const SHORTCUTS = {
SPACE_UP: "mod+up",
SPACE_DOWN: "mod+down",
FIND_IN_CONVERSATION: "mod+f",
+ MESSAGE_PREV: "alt+up",
+ MESSAGE_NEXT: "alt+down",
+ MESSAGE_JUMP: "mod+j",
BLUR: "escape",
SUBMIT_BLUR: "mod+enter",
SWITCH_MESSAGING_MODE: "mod+s",
@@ -203,6 +206,27 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
category: "panels",
context: "Task detail",
},
+ {
+ id: "message-prev",
+ keys: SHORTCUTS.MESSAGE_PREV,
+ description: "Previous message",
+ category: "panels",
+ context: "Task detail",
+ },
+ {
+ id: "message-next",
+ keys: SHORTCUTS.MESSAGE_NEXT,
+ description: "Next message",
+ category: "panels",
+ context: "Task detail",
+ },
+ {
+ id: "message-jump",
+ keys: SHORTCUTS.MESSAGE_JUMP,
+ description: "Jump to message",
+ category: "panels",
+ context: "Task detail",
+ },
{
id: "paste-as-file",
keys: SHORTCUTS.PASTE_AS_FILE,
diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx
index 59a446014f..c3f39d0f9c 100644
--- a/packages/ui/src/features/sessions/components/ConversationView.tsx
+++ b/packages/ui/src/features/sessions/components/ConversationView.tsx
@@ -10,11 +10,14 @@ import {
} from "@posthog/quill";
import type { AcpMessage } from "@posthog/shared";
import type { Task } from "@posthog/shared/domain-types";
+import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
import type {
ConversationItem,
TurnContext,
} from "@posthog/ui/features/sessions/components/buildConversationItems";
import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar";
+import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker";
+import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys";
import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage";
import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult";
import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems";
@@ -60,6 +63,7 @@ import {
} from "@posthog/ui/shell/diffWorkerHost";
import { Box, Flex, Text } from "@radix-ui/themes";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
export interface ConversationViewProps {
events: AcpMessage[];
@@ -220,6 +224,90 @@ export function ConversationView({
listRef: searchListRef,
});
+ const [jumpPickerOpen, setJumpPickerOpen] = useState(false);
+ const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState<
+ string | null
+ >(null);
+
+ const userMessages = useMemo(() => {
+ const result: Array<{ id: string; index: number }> = [];
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ if (item.type === "user_message") {
+ result.push({ id: item.id, index: i });
+ }
+ }
+ return result;
+ }, [items]);
+
+ // Grouped rows != items, so scroll by the row the message landed in (same
+ // mapping search uses), falling back to the raw item index.
+ const scrollToUserMessage = useCallback((id: string, itemIndex: number) => {
+ const rowIndex = itemIdToRowIndexRef.current.get(id) ?? itemIndex;
+ listRef.current?.scrollToIndex(rowIndex);
+ }, []);
+
+ const handleNavigateMessage = useCallback(
+ (direction: -1 | 1) => {
+ if (userMessages.length === 0) return;
+
+ const currentIndex = keyboardFocusedMessageId
+ ? userMessages.findIndex(
+ (message) => message.id === keyboardFocusedMessageId,
+ )
+ : -1;
+
+ const nextIndex =
+ currentIndex === -1
+ ? direction > 0
+ ? 0
+ : userMessages.length - 1
+ : Math.max(
+ 0,
+ Math.min(userMessages.length - 1, currentIndex + direction),
+ );
+
+ const nextMessage = userMessages[nextIndex];
+ if (!nextMessage) return;
+
+ setKeyboardFocusedMessageId(nextMessage.id);
+ scrollToUserMessage(nextMessage.id, nextMessage.index);
+ },
+ [keyboardFocusedMessageId, userMessages, scrollToUserMessage],
+ );
+
+ useHotkeys(
+ SHORTCUTS.MESSAGE_JUMP,
+ () => setJumpPickerOpen((prev) => !prev),
+ THREAD_HOTKEY_OPTIONS,
+ );
+
+ useHotkeys(
+ SHORTCUTS.MESSAGE_PREV,
+ () => handleNavigateMessage(-1),
+ THREAD_HOTKEY_OPTIONS,
+ );
+
+ useHotkeys(
+ SHORTCUTS.MESSAGE_NEXT,
+ () => handleNavigateMessage(1),
+ THREAD_HOTKEY_OPTIONS,
+ );
+
+ const clearKeyboardFocus = useCallback(() => {
+ setKeyboardFocusedMessageId(null);
+ }, []);
+
+ const handleJumpToMessage = useCallback(
+ (id: string) => {
+ const message = userMessages.find((entry) => entry.id === id);
+ if (!message) return;
+ setKeyboardFocusedMessageId(id);
+ scrollToUserMessage(id, message.index);
+ },
+ [userMessages, scrollToUserMessage],
+ );
+
const handleScrollStateChange = useCallback((isAtBottom: boolean) => {
isAtBottomRef.current = isAtBottom;
setShowScrollButton(!isAtBottom);
@@ -254,6 +342,7 @@ export function ConversationView({
timestamp={item.timestamp}
animate={!initialItemIds.has(item.id)}
taskId={taskId}
+ keyboardFocused={item.id === keyboardFocusedMessageId}
sourceUrl={
slackThreadUrl && item.id === firstUserMessageId
? slackThreadUrl
@@ -287,7 +376,14 @@ export function ConversationView({
return ;
}
},
- [repoPath, taskId, slackThreadUrl, firstUserMessageId, initialItemIds],
+ [
+ repoPath,
+ taskId,
+ slackThreadUrl,
+ firstUserMessageId,
+ initialItemIds,
+ keyboardFocusedMessageId,
+ ],
);
const getRowKey = useCallback((row: ThreadRow) => row.id, []);
@@ -359,7 +455,11 @@ export function ConversationView({
poolOptions={diffsPoolOptions}
highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS}
>
-
+
)}
+
+
ref={listRef}
diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx
index ef56e04229..6e82d78d3d 100644
--- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx
+++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx
@@ -30,6 +30,7 @@ import {
useChatMessageScrollerVisibility,
} from "@posthog/quill";
import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared";
+import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
import { useSmoothedText } from "@posthog/ui/features/editor/components/useSmoothedText";
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore";
@@ -40,10 +41,12 @@ import {
} from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown";
import { ChatThreadFooter } from "@posthog/ui/features/sessions/components/chat-thread/ChatThreadFooter";
import { ChatThreadChromeProvider } from "@posthog/ui/features/sessions/components/chat-thread/chatThreadChrome";
+import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker";
import {
ToolGroup,
type ToolGroupItem,
} from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup";
+import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys";
import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage";
import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult";
import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems";
@@ -88,6 +91,8 @@ import {
useRef,
useState,
} from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+
import type { ConversationViewProps } from "../ConversationView";
/** A row is either a parsed conversation item or a synthesized group of tool calls. */
@@ -261,10 +266,12 @@ function UserBubble({
content,
timestamp,
attachments = [],
+ keyboardFocused = false,
}: {
content: string;
timestamp?: number;
attachments?: UserMessageAttachment[];
+ keyboardFocused?: boolean;
}) {
const bluebirdEnabled = useFeatureFlag(
PROJECT_BLUEBIRD_FLAG,
@@ -363,7 +370,14 @@ function UserBubble({
)}
)}
-
+
ReactNode;
isTrailing?: boolean;
+ keyboardFocused?: boolean;
}) {
if (item.type === "tool_group") {
const context = item.tools[0]?.turnContext;
@@ -638,6 +654,7 @@ function ThreadItemBody({
content={item.content}
timestamp={item.timestamp}
attachments={item.attachments}
+ keyboardFocused={keyboardFocused}
/>
);
}
@@ -669,9 +686,11 @@ function completedTurnTimestamp(turn: AgentTurn): number | undefined {
const ThreadRow = memo(function ThreadRow({
item,
renderItem,
+ keyboardFocused,
}: {
item: TurnRow;
renderItem: (item: ConversationItem) => ReactNode;
+ keyboardFocused?: boolean;
}) {
if (item.type === "agent_turn") {
return (
@@ -711,7 +730,11 @@ const ThreadRow = memo(function ThreadRow({
className="mx-auto w-full px-2.5 py-1 empty:hidden"
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
>
-
+
);
});
@@ -782,18 +805,122 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
return
;
}
+/**
+ * Keyboard message navigation (Alt/Option+Up/Down) and the Cmd/Ctrl+J jump picker. Rendered inside
+ * `ChatMessageScrollerProvider` so it can call `scrollToMessage` from the engine — the same primitive
+ * `StickyHeaderOverlay` uses to jump back to the anchored turn.
+ */
+function ThreadKeyboardNav({
+ items,
+ jumpPickerOpen,
+ setJumpPickerOpen,
+ keyboardFocusedMessageId,
+ setKeyboardFocusedMessageId,
+}: {
+ items: ConversationItem[];
+ jumpPickerOpen: boolean;
+ setJumpPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
+ keyboardFocusedMessageId: string | null;
+ setKeyboardFocusedMessageId: (id: string | null) => void;
+}) {
+ const { scrollToMessage } = useChatMessageScroller();
+
+ const userMessageIds = useMemo(
+ () =>
+ items
+ .filter(
+ (item): item is Extract
=>
+ item.type === "user_message",
+ )
+ .map((item) => item.id),
+ [items],
+ );
+
+ useHotkeys(
+ SHORTCUTS.MESSAGE_JUMP,
+ () => setJumpPickerOpen((prev) => !prev),
+ THREAD_HOTKEY_OPTIONS,
+ );
+
+ const handleNavigateMessage = useCallback(
+ (direction: -1 | 1) => {
+ if (userMessageIds.length === 0) return;
+
+ const currentIndex = keyboardFocusedMessageId
+ ? userMessageIds.indexOf(keyboardFocusedMessageId)
+ : -1;
+
+ const nextIndex =
+ currentIndex === -1
+ ? direction > 0
+ ? 0
+ : userMessageIds.length - 1
+ : Math.max(
+ 0,
+ Math.min(userMessageIds.length - 1, currentIndex + direction),
+ );
+
+ const nextId = userMessageIds[nextIndex];
+ if (!nextId) return;
+
+ setKeyboardFocusedMessageId(nextId);
+ scrollToMessage(nextId);
+ },
+ [
+ keyboardFocusedMessageId,
+ userMessageIds,
+ setKeyboardFocusedMessageId,
+ scrollToMessage,
+ ],
+ );
+
+ useHotkeys(
+ SHORTCUTS.MESSAGE_PREV,
+ () => handleNavigateMessage(-1),
+ THREAD_HOTKEY_OPTIONS,
+ );
+
+ useHotkeys(
+ SHORTCUTS.MESSAGE_NEXT,
+ () => handleNavigateMessage(1),
+ THREAD_HOTKEY_OPTIONS,
+ );
+
+ const handleJumpToMessage = useCallback(
+ (id: string) => {
+ setKeyboardFocusedMessageId(id);
+ scrollToMessage(id);
+ },
+ [scrollToMessage, setKeyboardFocusedMessageId],
+ );
+
+ return (
+
+ );
+}
+
/** The scroll body, under the Provider so the overlay + scroll-button hooks can read engine state. */
function ThreadScrollBody({
items,
rows,
renderItem,
footer,
+ keyboardFocusedMessageId,
+ onUserInteract,
}: {
items: ConversationItem[];
rows: TurnRow[];
renderItem: (item: ConversationItem) => ReactNode;
/** Status row (duration / context usage) pinned as the last item in the thread. */
footer?: ReactNode;
+ keyboardFocusedMessageId?: string | null;
+ /** Clears keyboard-focused message state on any pointer interaction with the thread. */
+ onUserInteract?: () => void;
}) {
const keyedRows = useMemo(() => {
let userTurn = 0;
@@ -806,7 +933,10 @@ function ThreadScrollBody({
// `group/thread` so the footer's hover-reveal (opacity-50 → 100 on group-hover) tracks the thread,
// mirroring the legacy ConversationView container.
return (
-
+
@@ -815,7 +945,12 @@ function ThreadScrollBody({
density="default"
>
{keyedRows.map(({ item, key }) => (
-
+
))}
{footer && (
(null);
+ const clearKeyboardFocus = useCallback(() => {
+ setKeyboardFocusedMessageId(null);
+ }, []);
+
const renderItem = useCallback(
(item: ConversationItem) => {
switch (item.type) {
@@ -968,6 +1111,8 @@ export function ChatThread({
items={items}
rows={rows}
renderItem={renderItem}
+ keyboardFocusedMessageId={keyboardFocusedMessageId}
+ onUserInteract={clearKeyboardFocus}
footer={
}
/>
+
diff --git a/packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx b/packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx
new file mode 100644
index 0000000000..abcaa888ad
--- /dev/null
+++ b/packages/ui/src/features/sessions/components/chat-thread/MessageJumpPicker.tsx
@@ -0,0 +1,510 @@
+import { ChatText, Check, FunnelSimple, X } from "@phosphor-icons/react";
+import {
+ Autocomplete,
+ AutocompleteInput,
+ AutocompleteItem,
+ AutocompleteList,
+ Dialog,
+ DialogContent,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@posthog/quill";
+import { CommandKeyHints } from "@posthog/ui/features/command/CommandKeyHints";
+import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
+import { Flex } from "@radix-ui/themes";
+import { useCallback, useMemo, useState } from "react";
+
+interface MessageJumpPickerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ items: ConversationItem[];
+ /** Scrolls the thread to the message with this id (ChatThread addresses rows by id, not index). */
+ onJumpToMessage: (id: string) => void;
+}
+
+interface JumpEntry {
+ id: string;
+ label: string;
+ fullText: string;
+ timestamp: number;
+}
+
+type DatePreset =
+ | "today"
+ | "yesterday"
+ | "last7d"
+ | "last14d"
+ | "thisMonth"
+ | "lastMonth"
+ | "last30d"
+ | "last90d"
+ | "last6mo"
+ | "thisYear"
+ | "lastYear";
+
+interface PresetConfig {
+ label: string;
+ footerLabel: string;
+ getRange: () => { from: number; to: number };
+}
+
+const DATE_PRESETS: Record
= {
+ today: {
+ label: "Today",
+ footerLabel: "today",
+ getRange: () => {
+ const start = new Date();
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ yesterday: {
+ label: "Yesterday",
+ footerLabel: "yesterday",
+ getRange: () => {
+ const d = new Date();
+ d.setDate(d.getDate() - 1);
+ const start = new Date(d);
+ start.setHours(0, 0, 0, 0);
+ const end = new Date(d);
+ end.setHours(23, 59, 59, 999);
+ return { from: start.getTime(), to: end.getTime() };
+ },
+ },
+ last7d: {
+ label: "Last 7 days",
+ footerLabel: "last 7 days",
+ getRange: () => {
+ const start = new Date();
+ start.setDate(start.getDate() - 7);
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ last14d: {
+ label: "Last 14 days",
+ footerLabel: "last 14 days",
+ getRange: () => {
+ const start = new Date();
+ start.setDate(start.getDate() - 14);
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ thisMonth: {
+ label: "This month",
+ footerLabel: "this month",
+ getRange: () => {
+ const start = new Date();
+ start.setDate(1);
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ lastMonth: {
+ label: "Last month",
+ footerLabel: "last month",
+ getRange: () => {
+ const now = new Date();
+ const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ const end = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ 0,
+ 23,
+ 59,
+ 59,
+ 999,
+ );
+ return { from: start.getTime(), to: end.getTime() };
+ },
+ },
+ last30d: {
+ label: "Last 30 days",
+ footerLabel: "last 30 days",
+ getRange: () => {
+ const start = new Date();
+ start.setDate(start.getDate() - 30);
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ last90d: {
+ label: "Last 90 days",
+ footerLabel: "last 90 days",
+ getRange: () => {
+ const start = new Date();
+ start.setDate(start.getDate() - 90);
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ last6mo: {
+ label: "Last 6 months",
+ footerLabel: "last 6 months",
+ getRange: () => {
+ const start = new Date();
+ start.setMonth(start.getMonth() - 6);
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ thisYear: {
+ label: "This year",
+ footerLabel: "this year",
+ getRange: () => {
+ const start = new Date();
+ start.setMonth(0, 1);
+ start.setHours(0, 0, 0, 0);
+ return { from: start.getTime(), to: Date.now() };
+ },
+ },
+ lastYear: {
+ label: "Last year",
+ footerLabel: "last year",
+ getRange: () => {
+ const year = new Date().getFullYear() - 1;
+ const start = new Date(year, 0, 1);
+ const end = new Date(year, 11, 31, 23, 59, 59, 999);
+ return { from: start.getTime(), to: end.getTime() };
+ },
+ },
+};
+
+const PRESET_ORDER: DatePreset[] = [
+ "today",
+ "yesterday",
+ "last7d",
+ "last14d",
+ "thisMonth",
+ "lastMonth",
+ "last30d",
+ "last90d",
+ "last6mo",
+ "thisYear",
+ "lastYear",
+];
+
+const MAX_LABEL_LENGTH = 120;
+
+function formatTimestamp(ts: number): string {
+ return new Date(ts).toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+function formatDateTimeShort(datetimeStr: string): string {
+ return new Date(datetimeStr).toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+function truncate(text: string, maxLength: number): string {
+ const singleLine = text.replace(/\n+/g, " ").trim();
+ if (singleLine.length <= maxLength) return singleLine;
+ return `${singleLine.slice(0, maxLength)}…`;
+}
+
+/** A midnight "to" bound means the user picked a day without a time, so include that whole day. */
+function customRangeEnd(value: string): number {
+ const end = new Date(value);
+ if (end.getHours() === 0 && end.getMinutes() === 0) {
+ end.setHours(23, 59, 59, 999);
+ }
+ return end.getTime();
+}
+
+export function MessageJumpPicker({
+ open,
+ onOpenChange,
+ items,
+ onJumpToMessage,
+}: MessageJumpPickerProps) {
+ // The body only mounts while the dialog is open, so every open starts with clean filter state.
+ return (
+
+ );
+}
+
+function JumpPickerBody({
+ items,
+ onJumpToMessage,
+ onOpenChange,
+}: {
+ items: ConversationItem[];
+ onJumpToMessage: (id: string) => void;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [query, setQuery] = useState("");
+ const [activePreset, setActivePreset] = useState(null);
+ const [showCustom, setShowCustom] = useState(false);
+ const [customFrom, setCustomFrom] = useState("");
+ const [customTo, setCustomTo] = useState("");
+
+ const entries = useMemo(() => {
+ const result: JumpEntry[] = [];
+ for (const item of items) {
+ if (item.type === "user_message") {
+ result.push({
+ id: item.id,
+ label: truncate(item.content, MAX_LABEL_LENGTH),
+ fullText: item.content,
+ timestamp: item.timestamp,
+ });
+ }
+ }
+ return result;
+ }, [items]);
+
+ const visibleEntries = useMemo(() => {
+ let filtered = entries;
+
+ if (activePreset !== null) {
+ const { from, to } = DATE_PRESETS[activePreset].getRange();
+ filtered = filtered.filter(
+ (e) => e.timestamp >= from && e.timestamp <= to,
+ );
+ } else if (showCustom) {
+ if (customFrom) {
+ filtered = filtered.filter(
+ (e) => e.timestamp >= new Date(customFrom).getTime(),
+ );
+ }
+ if (customTo) {
+ const end = customRangeEnd(customTo);
+ filtered = filtered.filter((e) => e.timestamp <= end);
+ }
+ }
+
+ const normalizedQuery = query.trim().toLowerCase();
+ if (normalizedQuery) {
+ filtered = filtered.filter((entry) =>
+ entry.fullText.toLowerCase().includes(normalizedQuery),
+ );
+ }
+
+ return filtered;
+ }, [entries, query, activePreset, showCustom, customFrom, customTo]);
+
+ const footerFilterLabel = useMemo((): string | null => {
+ if (activePreset !== null) return DATE_PRESETS[activePreset].footerLabel;
+ if (showCustom) {
+ if (customFrom && customTo) {
+ return `${formatDateTimeShort(customFrom)} – ${formatDateTimeShort(customTo)}`;
+ }
+ if (customFrom) return `after ${formatDateTimeShort(customFrom)}`;
+ if (customTo) return `before ${formatDateTimeShort(customTo)}`;
+ }
+ return null;
+ }, [activePreset, showCustom, customFrom, customTo]);
+
+ const triggerLabel = useMemo((): string => {
+ if (activePreset !== null) return DATE_PRESETS[activePreset].label;
+ if (showCustom && (customFrom || customTo)) return "Custom";
+ return "Filter";
+ }, [activePreset, showCustom, customFrom, customTo]);
+
+ const filterActive =
+ activePreset !== null ||
+ (showCustom && (customFrom !== "" || customTo !== ""));
+
+ const handlePreset = useCallback((preset: DatePreset) => {
+ setActivePreset((current) => (current === preset ? null : preset));
+ setShowCustom(false);
+ setCustomFrom("");
+ setCustomTo("");
+ }, []);
+
+ const handleCustom = useCallback(() => {
+ setActivePreset(null);
+ setShowCustom(true);
+ }, []);
+
+ const clearCustom = useCallback(() => {
+ setCustomFrom("");
+ setCustomTo("");
+ setShowCustom(false);
+ }, []);
+
+ const handleSelect = useCallback(
+ (id: string | null) => {
+ if (id === null) return;
+ const entry = visibleEntries.find((e) => e.id === id);
+ if (!entry) return;
+ onJumpToMessage(entry.id);
+ onOpenChange(false);
+ },
+ [visibleEntries, onJumpToMessage, onOpenChange],
+ );
+
+ return (
+ <>
+
+ inline
+ defaultOpen
+ items={visibleEntries}
+ filter={null}
+ value={query}
+ autoHighlight="always"
+ onValueChange={(val, eventDetails) => {
+ if (eventDetails.reason !== "input-change") return;
+ if (typeof val === "string") {
+ setQuery(val);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {triggerLabel}
+
+ }
+ />
+
+
+ {PRESET_ORDER.map((preset) => (
+ handlePreset(preset)}
+ className="flex items-center justify-between"
+ >
+ {DATE_PRESETS[preset].label}
+ {activePreset === preset && (
+
+ )}
+
+ ))}
+
+
+
+ Custom range…
+
+
+
+
+
+
+
+ {showCustom && (
+
+ setCustomFrom(e.target.value)}
+ className="h-[22px] min-w-0 flex-1 rounded-(--radius-1) border border-(--gray-a5) bg-(--gray-a2) px-1.5 text-(--gray-12) text-[12px] tabular-nums outline-none focus:border-(--accent-8)"
+ />
+ –
+ setCustomTo(e.target.value)}
+ className="h-[22px] min-w-0 flex-1 rounded-(--radius-1) border border-(--gray-a5) bg-(--gray-a2) px-1.5 text-(--gray-12) text-[12px] tabular-nums outline-none focus:border-(--accent-8)"
+ />
+
+
+ )}
+
+
+ {(entry: JumpEntry) => (
+ handleSelect(entry.id)}
+ className="group/entry h-auto! min-h-7 py-1.5 text-left"
+ >
+
+
+ {entry.label}
+
+
+ {formatTimestamp(entry.timestamp)}
+
+
+ )}
+
+
+
+
+ {visibleEntries.length}{" "}
+ {visibleEntries.length === 1 ? "message" : "messages"}
+ {footerFilterLabel !== null && (
+ ({footerFilterLabel})
+ )}
+
+
+
+ >
+ );
+}
diff --git a/packages/ui/src/features/sessions/components/chat-thread/threadHotkeys.ts b/packages/ui/src/features/sessions/components/chat-thread/threadHotkeys.ts
new file mode 100644
index 0000000000..d606651a9d
--- /dev/null
+++ b/packages/ui/src/features/sessions/components/chat-thread/threadHotkeys.ts
@@ -0,0 +1,17 @@
+export const THREAD_HOTKEY_OPTIONS = {
+ enableOnFormTags: true,
+ enableOnContentEditable: true,
+ // The composer holds focus whenever a task is open, so thread shortcuts must fire from it
+ // (`.cli-editor` is its ProseMirror root) and from the jump picker itself; every other editable
+ // surface keeps its keys — alt+arrows move lines in CodeMirror, ctrl+j is a newline in terminals.
+ ignoreEventWhen: (event: KeyboardEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return false;
+ const editable = target.closest(
+ 'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]',
+ );
+ if (!editable) return false;
+ return target.closest(".cli-editor, [data-message-jump-picker]") === null;
+ },
+ preventDefault: true,
+} as const;
diff --git a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx
index c4b0f80469..e6478da0e3 100644
--- a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx
+++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx
@@ -33,6 +33,7 @@ interface UserMessageProps {
animate?: boolean;
/** Task the message belongs to — needed to open the context file tab. */
taskId?: string;
+ keyboardFocused?: boolean;
}
function formatTimestamp(ts: number): string {
@@ -58,6 +59,7 @@ export const UserMessage = memo(function UserMessage({
attachments = [],
animate = true,
taskId,
+ keyboardFocused = false,
}: UserMessageProps) {
// A channel's CONTEXT.md and the canvas generation instructions, if injected
// into this prompt, are each collapsed into a clickable tag instead of
@@ -126,7 +128,7 @@ export const UserMessage = memo(function UserMessage({
transition={animate ? { duration: 0.25, ease: "easeOut" } : undefined}
>