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:
@@ -127,6 +127,46 @@ describe("MainTabs reordering", () => {
|
||||
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||
});
|
||||
|
||||
it("shows the tab itself under the cursor while dragging", () => {
|
||||
// A dimmed source tab and a thin line do not read as "I am holding this
|
||||
// tab" — the dragged copy is what makes the gesture legible.
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 120);
|
||||
|
||||
const ghost = screen.getByTestId("tab-drag-ghost");
|
||||
expect(ghost).toHaveTextContent("shell (bash)");
|
||||
expect(ghost).toHaveTextContent("▣");
|
||||
|
||||
pointer(tabs[2], "pointerup", 120);
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
});
|
||||
|
||||
it("carries the project name when a home tab is dragged", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
pointer(tabs[0], "pointerdown", 50);
|
||||
pointer(tabs[0], "pointermove", 250);
|
||||
|
||||
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("api-server");
|
||||
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("⌂");
|
||||
});
|
||||
|
||||
it("drops the dragged copy when the drag is abandoned", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 10);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the drop marker only while a drag is under way", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -374,8 +374,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Handle backend output -> terminal
|
||||
let aborted = false;
|
||||
|
||||
const detector = new UrlDetector((url) =>
|
||||
promptUrl(url, "Long URL detected"),
|
||||
// The width is read per scan, not captured: only a break the terminal
|
||||
// itself inserted may be deleted, and where that is moves with every
|
||||
// resize.
|
||||
const detector = new UrlDetector(
|
||||
(url) => promptUrl(url, "Long URL detected"),
|
||||
() => termRef.current?.cols ?? 0,
|
||||
);
|
||||
detectorRef.current = detector;
|
||||
|
||||
@@ -552,7 +556,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
if (!projectId) return;
|
||||
// A sign-in page is the one case where the *window* size matters least and
|
||||
// the layout matters most, so it gets the ordinary desktop viewport.
|
||||
openPageInContainerBrowser(projectId, safe, 1280, 720)
|
||||
// `true`: from a terminal there is no Browser pane on screen, so the page
|
||||
// needs a window of its own or it opens somewhere the user isn't looking.
|
||||
openPageInContainerBrowser(projectId, safe, 1280, 720, true)
|
||||
.then((result) => {
|
||||
const push = useAppState.getState().pushToast;
|
||||
if (result.error) {
|
||||
@@ -560,7 +566,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
} else {
|
||||
push({
|
||||
kind: "success",
|
||||
message: "Opened in the container’s browser — see the project’s Browser tab",
|
||||
message: "Opened in the container’s browser",
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user