Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
## [Unreleased]

### Added
- **Two tabs side by side** on a desktop, from different workspaces or PCs: drag a tab down out
of its strip, or a sidebar row over the pane area, and drop it on the left or right half. A guide
shows the two halves as soon as the drag starts and keeps its side steady at the centre; Escape
or a drop elsewhere leaves everything as it was. Each half draws its tab at herdr's own layout,
with its own tab strip and a bar with its lens and a close, and a tab is never shown in both
halves. A press in a half makes it the one the header, the palette and the shortcuts act on.
The split is kept across a reload, and a narrower window shows the active half alone. Drag
guide by @flam-ing. ([#861](https://github.com/devswha/herdr-web-ui/pull/861) by @Yoonwoo-Ha)
- The file viewer and Markdown reader page offer **Show source**, highlighted source with
CSS-counter line numbers, and **Copy file**, which selects the source when copying is unavailable.
**Settings → Chat → File viewer** controls line wrapping and Markdown width; previews inherit
Expand Down
589 changes: 589 additions & 0 deletions scripts/split-view-regression.ts

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions scripts/ui-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { checkHeldDraftPaneSwitch } from "./terminal-draft-regression.ts";
import { checkDefaultView } from "./default-view-regression.ts";
import { checkComposerReconnect } from "./composer-reconnect-regression.ts";
import { checkDroplet } from "./droplet-regression.ts";
import { checkSplitView } from "./split-view-regression.ts";
import { checkTakeOver } from "./take-over-regression.ts";
import { checkAlertSound } from "./alert-sound-regression.ts";
import { checkBackgroundTabKeepsTerminalSize, checkChatKeepsTerminalSize, checkFocusedWindowReclaimsTerminalSize, checkPaneSwitchKeepsTerminalSize } from "./chat-size-regression.ts";
Expand Down Expand Up @@ -863,6 +864,7 @@ try {
await checkDefaultView(browser, origin);
await checkComposerReconnect(browser, origin, paneB);
await checkDroplet(browser, origin);
await checkSplitView(browser, origin);
await checkTakeOver(browser, origin);
await checkAlertSound(browser, origin);
await checkChatKeepsTerminalSize(browser, origin);
Expand Down Expand Up @@ -1151,6 +1153,7 @@ try {
assert.equal(await composer.inputValue(), "draft for A");
console.log("PASS drafts stay with their panes");


// A real successful send waits on its acknowledgement while its composer unmounts.
for (const returnBeforeAck of [false, true]) {
await composer.fill("# confirmed draft");
Expand Down Expand Up @@ -1789,6 +1792,37 @@ try {
await selectPane(paneA);
console.log("PASS Send now on a server-owned pending message drops Claude's suggestion");

// Split view (lib/split.ts): a sidebar row dragged to the right half opens its pane there beside
// the one open, each half with its own conversation; a press in the other half makes it the
// active one without moving either pane, and closing a half leaves one pane
// (after the steps that take the newest socket for the page's own: the other half opens one)
{
await selectPane(paneA);
const area = page.locator(".pane-split");
const box = (await area.boundingBox())!;
await page.locator(`.pane-select[title^="${paneB} —"]`).dragTo(area, { targetPosition: { x: box.width * 0.8, y: box.height / 2 } });
await until(async () => await page.locator(".pane-slot").count() === 2, "two halves after the drop");
const half = (side: string) => page.locator(`.pane-slot[data-side="${side}"]`);
await half("left").getByRole("log", { name: `conversation of ${paneA}`, exact: true }).waitFor();
await half("right").getByRole("log", { name: `conversation of ${paneB}`, exact: true }).waitFor();
assert.equal(await half("right").evaluate((slot) => slot.classList.contains("is-active")), true, "the dropped pane's half is active");
assert.equal(await page.locator(".pane-select[aria-current=\"true\"]").getAttribute("title").then((title) => title?.startsWith(`${paneB} —`)), true, "and its pane is the selected one");
const [left, right] = await Promise.all([half("left").boundingBox(), half("right").boundingBox()]);
assert.ok(left && right && Math.abs(left.width - right.width) <= 2 && left.x + left.width <= right.x + 1, "the halves share the pane area side by side");
await half("left").locator(".terminal-host").click({ position: { x: 40, y: 40 } });
await until(async () => await half("left").evaluate((slot) => slot.classList.contains("is-active")), "a press makes the left half active");
// the header speaks for the active half's connection, handed over by a terminal that was already connected
await page.locator(".conn.conn-live").waitFor();
await half("left").getByRole("log", { name: `conversation of ${paneA}`, exact: true }).waitFor();
await half("right").getByRole("log", { name: `conversation of ${paneB}`, exact: true }).waitFor();
assert.match(await page.evaluate(() => sessionStorage.getItem("herdr-web-ui:split") ?? ""), /"active":"a"/, "the split is kept for a reload");
await half("right").getByRole("button", { name: "Close this half", exact: true }).click();
await until(async () => await page.locator(".pane-slot").count() === 1, "one pane after closing a half");
assert.equal(await page.evaluate(() => sessionStorage.getItem("herdr-web-ui:split")), null);
await page.getByRole("log", { name: `conversation of ${paneA}`, exact: true }).waitFor();
}
console.log("PASS a dragged row opens its pane beside the open one; a press switches the active half; a half closes");

const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
await mobile.addInitScript(() => {
Storage.prototype.getItem = () => { throw new DOMException("Storage unavailable", "SecurityError"); };
Expand Down
Loading
Loading