diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 45bb7936298d7..645226926211e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -444,7 +444,6 @@ export function resolveTimelineMinimapHasPersistentGutter( const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; -const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; export function resolveTimelineMinimapHitStripWidth( viewportWidth: number, @@ -466,13 +465,6 @@ export function resolveTimelineMinimapNavigationInteractive(collapsedWidth: numb return collapsedWidth >= TIMELINE_MINIMAP_NAVIGATION_REACH; } -export function resolveTimelineMinimapInteractiveWidth( - collapsedWidth: number, - expanded: boolean, -): number | string { - return expanded ? TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH : collapsedWidth; -} - function computeElapsedMs(startIso: string, endIso: string): number | null { const start = Date.parse(startIso); const end = Date.parse(endIso); diff --git a/apps/web/src/components/chat/TimelineMinimap.tsx b/apps/web/src/components/chat/TimelineMinimap.tsx index c4cc203e8a75f..776576d72258f 100644 --- a/apps/web/src/components/chat/TimelineMinimap.tsx +++ b/apps/web/src/components/chat/TimelineMinimap.tsx @@ -5,7 +5,6 @@ import { Button } from "../ui/button"; import { resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, - resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, TIMELINE_MINIMAP_MIN_ITEMS, @@ -13,10 +12,6 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { - return target instanceof Element && target.closest("[data-minimap-preview]") !== null; -} - // A release outside the window can skip `mouseup`, so the next button-free // `mousemove` also counts. function onMouseButtonsReleased(onRelease: () => void): () => void { @@ -147,7 +142,7 @@ export function TimelineMinimap({ }} style={{ height: resolveTimelineMinimapHeightStyle(items.length), - width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null), + width: hitStripWidth, }} > setActiveIndex(null)} onClick={(event) => { - if (timelineMinimapEventTargetsPreview(event.target)) { - return; - } const nextIndex = resolveActiveIndexFromPointer(event); const selectedItem = nextIndex === null ? null : (items[nextIndex] ?? null); if (selectedItem) { @@ -199,12 +191,7 @@ export function TimelineMinimap({ if (event.buttons !== 0 && !pressedOnStripRef.current) return; updateActiveIndexFromPointer(event); }} - onMouseDown={(event) => { - if (timelineMinimapEventTargetsPreview(event.target)) { - return; - } - event.preventDefault(); - }} + onMouseDown={(event) => event.preventDefault()} type="button" >
@@ -247,10 +234,10 @@ export function TimelineMinimap({ ); })} {activeItem ? ( + // The preview is display-only: it overlaps the message column, so + // taking pointer events would block selecting the text beneath it. event.stopPropagation()} + className="pointer-events-none absolute left-8 w-80" style={{ top: `${activeTopPercent}%`, transform: `translateY(${activeTooltipTranslate})`, diff --git a/scripts/fixtures/issue-16447.json b/scripts/fixtures/issue-16447.json new file mode 100644 index 0000000000000..39ee171c8594a --- /dev/null +++ b/scripts/fixtures/issue-16447.json @@ -0,0 +1,6 @@ +{ + "projectTitle": "Minimap synthetic evidence", + "user": "Inspect paragraph {n}: verify that text selection, the reference link, and wheel scrolling remain available beneath the timeline preview. This deliberately long user heading exercises compact preview truncation without including private conversation data.", + "assistant": "### Synthetic paragraph {n}\n\nSelectable text begins at the left content edge. Amber birds cross the quiet valley while readers compare navigation behavior. Drag across this sentence to select the underlying message, then move back into the narrow timeline rail. The preview should remain a display of context while the conversation stays usable.\n\n[Reference link](https://example.com/#synthetic-{n})\n\n**Result:** this is fixed synthetic markdown with a short list:\n\n- Navigation stays intentional.\n- Selection belongs to the conversation.\n- Scroll position can be reproduced.\n\n", + "longTail": "Additional synthetic context exercises the preview length cap. Cedar trees line a river beneath a clear sky. " +} diff --git a/scripts/fixtures/issue-16447.py b/scripts/fixtures/issue-16447.py new file mode 100644 index 0000000000000..e0c8156ce2d36 --- /dev/null +++ b/scripts/fixtures/issue-16447.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Seed UI-only projections in this worktree's .t3, with its dev server STOPPED. + +Run from the repository root: python3 scripts/fixtures/issue-16447.py +Requires an already migrated .t3/userdata/statev2.sqlite. Removes copied project +and conversation projections so no private sidebar/history appears in evidence. +Does not change events or claim backend behavior. Never targets live T3 home. + +Deletes every projection table, so it refuses to touch the database until you +mark it disposable yourself, after copying in data you can lose: + echo issue-16447-disposable > .t3/userdata/statev2.sqlite.issue-16447-disposable +""" +import datetime +import json +from pathlib import Path +import sqlite3 + +root = Path.cwd().resolve() +db = root / '.t3/userdata/statev2.sqlite' +if not (root / '.git').exists() or not db.exists() or db.resolve() == Path.home() / '.t3/userdata/statev2.sqlite': + raise SystemExit('Run in the isolated, migrated worktree only') +marker = db.with_name(db.name + '.issue-16447-disposable') +if not marker.is_file() or marker.read_text().strip() != 'issue-16447-disposable': + raise SystemExit(f'Refusing to delete projections: create {marker} as described in this script\'s docstring') +fixture = json.loads(Path(__file__).with_suffix('.json').read_text()) +c = sqlite3.connect(db) +c.execute('PRAGMA foreign_keys=OFF') + +def insert(table, row): + keys = list(row) + c.execute(f'INSERT INTO {table} ({",".join(keys)}) VALUES ({",".join("?" for _ in keys)})', list(row.values())) + +def stamp(n=0): + return (datetime.datetime(2026, 3, 17, 12, tzinfo=datetime.timezone.utc) + datetime.timedelta(seconds=n)).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + +def payload(table, obj, **columns): + insert('orchestration_v2_projection_' + table, dict(columns, payload_json=json.dumps(obj))) + +with c: + tables = [r[0] for r in c.execute("SELECT name FROM sqlite_schema WHERE type='table'")] + for table in tables: + if (table.startswith('orchestration_v2_projection_') and table != 'orchestration_v2_projection_metadata') or table.startswith('projection_'): + c.execute(f'DELETE FROM {table}') + insert('projection_projects', dict(project_id='synthetic-minimap', title=fixture['projectTitle'], workspace_root=str(root), scripts_json='[]', created_at=stamp(), updated_at=stamp())) + model = dict(instanceId='codex', model='gpt-5.4') + for label, count in [('long', 80), ('two', 2), ('one', 1), ('empty', 0)]: + tid = 'synthetic-minimap-' + label + title = f'Minimap {label} — synthetic fixture' + thread = dict(createdBy='user', creationSource='web', id=tid, projectId='synthetic-minimap', title=title, providerInstanceId='codex', modelSelection=model, runtimeMode='full-access', interactionMode='default', branch=None, worktreePath=None, linkedPullRequest=None, pullRequests=[], activeProviderThreadId=None, lineage=dict(parentThreadId=None, relationshipToParent=None, rootThreadId=tid), forkedFrom=None, createdAt=stamp(), updatedAt=stamp(), archivedAt=None, settledOverride='active', settledAt=None, lastVisitedAt=None, deletedAt=None) + payload('threads', thread, thread_id=tid, project_id='synthetic-minimap', title=title, default_provider='codex', provider_instance_id='codex', runtime_mode='full-access', interaction_mode='default', created_at=stamp(), updated_at=stamp()) + for i in range(1, count+1): + rid = f'{tid}-run-{i:03}' + uid = f'{rid}-user' + ts = stamp(i*10) + run = dict(id=rid, threadId=tid, ordinal=i, providerInstanceId='codex', modelSelection=model, providerThreadId=None, userMessageId=uid, rootNodeId=None, activeAttemptId=None, status='completed', queuePosition=None, requestedAt=ts, startedAt=ts, completedAt=stamp(i*10+2), checkpointId=None, contextHandoffId=None, workspacePreparation=dict(type='root')) + payload('runs', run, run_id=rid, thread_id=tid, ordinal=i, provider='codex', provider_instance_id='codex', status='completed', requested_at=ts, completed_at=stamp(i*10+2)) + for j, role in enumerate(['user', 'assistant']): + if label=='two' and i==1 and role=='assistant': + continue + text = fixture[role].replace('{n}', str(i)) + if label=='two': + text = (' ' if i==1 else 'Short user message') if role=='user' else 'Short final assistant.' + elif role=='assistant': + text += fixture['longTail'] * 18 + mid = uid if role=='user' else f'{rid}-assistant' + mt = stamp(i*10+j) + message = dict(createdBy='user' if role=='user' else 'agent', creationSource='web' if role=='user' else 'provider', id=mid, threadId=tid, runId=rid, nodeId=None, role=role, text=text, attachments=[], streaming=False, createdAt=mt, updatedAt=mt) + payload('messages', message, message_id=mid, thread_id=tid, run_id=rid, role=role, streaming=0, created_at=mt, updated_at=mt) + item = dict(id=mid+'-item', threadId=tid, runId=rid, nodeId=None, providerThreadId=None, providerTurnId=None, nativeItemRef=None, parentItemId=None, ordinal=i*1000000+j+1, status='completed', title=None, startedAt=mt, completedAt=mt, updatedAt=mt, type=role+'_message', messageId=mid, text=text, attachments=[]) + if role=='user': + item.update(createdBy='user', creationSource='web', inputIntent='turn_start') + else: + item['streaming']=False + payload('turn_items', item, turn_item_id=item['id'], thread_id=tid, run_id=rid, ordinal=item['ordinal'], type=item['type'], status='completed', updated_at=mt) + if label=='long' and i%5==0: + item = dict(id=rid+'-reasoning', threadId=tid, runId=rid, nodeId=None, providerThreadId=None, providerTurnId=None, nativeItemRef=None, parentItemId=None, ordinal=i*1000000+3, status='completed', title=None, startedAt=ts, completedAt=stamp(i*10+2), updatedAt=stamp(i*10+2), type='reasoning', text='Checked synthetic layout and navigation constraints.', streaming=False) + payload('turn_items', item, turn_item_id=item['id'], thread_id=tid, run_id=rid, ordinal=item['ordinal'], type=item['type'], status='completed', updated_at=item['updatedAt']) +print('Seeded synthetic-only projections: long=80, two=2, one=1, empty=0. No provider calls.')