Fix three things found by actually using it
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
**The drag showed no tab.** Moving to pointer events lost the drag image the OS used to supply, leaving a dimmed source tab and a 2px line — which reads as "some setting changed", not "I am holding this tab". A copy of the tab now follows the cursor, carrying its glyph and its real label, grabbed at the offset it was picked up by so it sits where the tab was. **The URL relay opened a different URL than the one on screen.** Observed: `repo.anhonesthost.net/…/tag/preview-63f3c54` arrived as `repo.anhonsthost.nt/…/preview-63f3c54Butitprovesyournitpick…`. The detector deleted *every* line break to undo PTY hard-wrapping, but a terminal that wraps at a space emits the break **instead of** the space — so deleting breaks also deletes the separators, gluing the following paragraph onto the link and running the match past the host. Only breaks the terminal inserted may be deleted, and those are exactly the ones at the column width. The detector now takes a live column getter and rejoins a line only when it is exactly that wide; every other break becomes a space, which is also what stops a URL match. Lines *longer* than the width are left alone — the stream had no break there, so the one that follows is the application's own. One case stays ambiguous: a URL whose length is an exact multiple of the width is indistinguishable from one that was cut. That is pinned in a test as known behaviour rather than papered over — the candidate is shown in full and nothing opens without the user pressing Open. **"In container" opened a page nobody could see.** It bound the browser and stopped, leaving the user to find the Browser tab and press Start, with nothing saying so — and from a terminal, no pane on screen at all. Opening a page now starts the viewer if it isn't running, and the terminal's prompt raises the pop-out window, because that caller has nowhere else to put it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,9 +55,21 @@ export default function MainTabs() {
|
||||
/** The tab being dragged, and the slot it would drop into. */
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
/** Where the dragged tab is drawn, and how it looked when the drag started. */
|
||||
const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>(
|
||||
null,
|
||||
);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
/** A press that has not yet moved far enough to be a drag. */
|
||||
const pending = useRef<{ key: string; startX: number; dragging: boolean } | null>(null);
|
||||
const pending = useRef<{
|
||||
key: string;
|
||||
startX: number;
|
||||
dragging: boolean;
|
||||
offsetX: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,6 +99,7 @@ export default function MainTabs() {
|
||||
pending.current = null;
|
||||
setDragKey(null);
|
||||
setDropIndex(null);
|
||||
setGhost(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
@@ -171,10 +184,29 @@ export default function MainTabs() {
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}${dragging ? " opacity-40" : ""}`;
|
||||
|
||||
/**
|
||||
* What a tab reads as, for the dragged copy. Same sources the tab itself
|
||||
* uses — a ghost showing a different name from the tab it came from would be
|
||||
* worse than no ghost.
|
||||
*/
|
||||
const tabLabel = (key: string): string => {
|
||||
if (isHomeTab(key)) {
|
||||
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
||||
}
|
||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||
if (!session) return "";
|
||||
const custom = getCustomName(session.projectId, session.id);
|
||||
return custom
|
||||
? `${session.projectName}: ${custom}`
|
||||
: (session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
pending.current = null;
|
||||
setDragKey(null);
|
||||
setDropIndex(null);
|
||||
setGhost(null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -212,7 +244,19 @@ export default function MainTabs() {
|
||||
// rename input is up — that drag is a text selection.
|
||||
if (e.button !== 0 || renaming) return;
|
||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||
pending.current = { key, startX: e.clientX, dragging: false };
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
pending.current = {
|
||||
key,
|
||||
startX: e.clientX,
|
||||
dragging: false,
|
||||
// Where inside the tab the pointer grabbed it, so the ghost sits under
|
||||
// the cursor exactly where the real tab was — the thing that makes a
|
||||
// drag feel like moving an object rather than nudging a setting.
|
||||
offsetX: e.clientX - rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: rect.top,
|
||||
};
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -223,6 +267,12 @@ export default function MainTabs() {
|
||||
drag.dragging = true;
|
||||
setDragKey(drag.key);
|
||||
setDropIndex(dropIndexAt(e.clientX));
|
||||
setGhost({
|
||||
x: e.clientX - drag.offsetX,
|
||||
y: drag.top,
|
||||
label: tabLabel(drag.key),
|
||||
icon: isHomeTab(drag.key) ? "⌂" : "▣",
|
||||
});
|
||||
},
|
||||
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = pending.current;
|
||||
@@ -408,6 +458,26 @@ export default function MainTabs() {
|
||||
the hand naturally goes to say "put it at the end". */}
|
||||
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
||||
|
||||
{ghost && (
|
||||
// A copy of the tab, following the pointer. Without it the only
|
||||
// feedback is a dimmed source and a thin line, which reads as "some
|
||||
// setting changed" rather than "I am holding this tab".
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="tab-drag-ghost"
|
||||
className="fixed z-50 flex items-center gap-1.5 px-3 h-8 text-xs rounded-[var(--radius-control)] bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--accent)] pointer-events-none"
|
||||
style={{
|
||||
left: ghost.x,
|
||||
top: ghost.y,
|
||||
boxShadow: "var(--shadow-overlay)",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
<span className="text-[var(--text-secondary)]">{ghost.icon}</span>
|
||||
<span className="truncate max-w-[180px]">{ghost.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu && (() => {
|
||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||
const hasCustom = session
|
||||
|
||||
Reference in New Issue
Block a user