copilot-operator 0.1.26 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/operator.service.js +1 -0
- package/dist/src/orchestrator/taskRunner.js +58 -9
- package/dist/src/transport/copilotTransport.js +258 -197
- package/dist/src/transport/locators.js +2 -0
- package/dist/web/404.html +2 -2
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +1 -1
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +1 -1
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +1 -1
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +1 -1
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +1 -1
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +1 -1
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +1 -1
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +1 -1
- package/package.json +3 -1
- /package/dist/web/_next/static/{jYvSWuPPywbxckIuz6cNs → MJtx49m8j0mFQ3oflKI-n}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{jYvSWuPPywbxckIuz6cNs → MJtx49m8j0mFQ3oflKI-n}/_ssgManifest.js +0 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 2. Enter does not submit the composer. The Send button has to be clicked.
|
|
11
11
|
* 3. A message cannot consist of an attachment alone; Send stays disabled without text.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { pageModelFor, sameModel } from './modelMatch.js';
|
|
14
14
|
import { chromium } from 'playwright';
|
|
15
15
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
16
16
|
import { join } from 'node:path';
|
|
@@ -39,9 +39,15 @@ export function isReplyTimeout(e) {
|
|
|
39
39
|
return e instanceof ReplyTimeoutError || (e instanceof Error && e.name === 'ReplyTimeoutError' && typeof e.seconds === 'number');
|
|
40
40
|
}
|
|
41
41
|
const ORIGIN = 'https://m365.cloud.microsoft';
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/**
|
|
43
|
+
* A group's name for the list. Its row reads the group ("GPT") until a model in it is chosen, and then
|
|
44
|
+
* that model's name; then the name comes from its test id (`gptSubMenuModelTrigger-OpenAI` → "GPT").
|
|
45
|
+
*/
|
|
46
|
+
function groupLabel(group, children) {
|
|
47
|
+
if (!children.some((c) => sameModel(c.name, group.name)))
|
|
48
|
+
return group.name;
|
|
49
|
+
const fromId = /^([a-z0-9]+?)SubMenu/i.exec(group.testId)?.[1];
|
|
50
|
+
return fromId ? fromId.toUpperCase() : group.raw.split('\n')[1]?.trim() || 'Models';
|
|
45
51
|
}
|
|
46
52
|
/**
|
|
47
53
|
* The chat refused the message: it never became a turn, so no reply is coming.
|
|
@@ -445,15 +451,17 @@ Current URL: ${url}`);
|
|
|
445
451
|
* Renames the current conversation. The sidebar row's overflow menu holds "Rename", the
|
|
446
452
|
* dialog holds an input and a Save button, and Enter does not submit it.
|
|
447
453
|
*/
|
|
448
|
-
async nameChat(chatId, name) {
|
|
454
|
+
async nameChat(chatId, name, opts = {}) {
|
|
449
455
|
const row = this.p.locator(`${Sidebar.conversationLink}[href*="${chatId}"]`).first();
|
|
450
|
-
// Waited for, like everything below: a new chat's row appears in the sidebar
|
|
451
|
-
// reply, and `count()` read it as missing and returned without a word (live run 2026-10-03).
|
|
456
|
+
// Waited for, like everything below: a new chat's row appears in the sidebar some seconds after the
|
|
457
|
+
// reply, and `count()` read it as missing and returned without a word (live run 2026-10-03). The run
|
|
458
|
+
// tries again later when it is still not there (see `nameIfStillUnnamed` in taskRunner.ts).
|
|
459
|
+
const waitMs = opts.waitMs ?? 15_000;
|
|
452
460
|
try {
|
|
453
|
-
await row.waitFor({ state: 'attached', timeout:
|
|
461
|
+
await row.waitFor({ state: 'attached', timeout: waitMs });
|
|
454
462
|
}
|
|
455
463
|
catch {
|
|
456
|
-
this.emit('chat-name-failed', { chatId, name, error:
|
|
464
|
+
this.emit('chat-name-failed', { chatId, name, error: `the conversation did not appear in the sidebar within ${Math.round(waitMs / 1000)} s` });
|
|
457
465
|
return false;
|
|
458
466
|
}
|
|
459
467
|
/*
|
|
@@ -467,23 +475,41 @@ Current URL: ${url}`);
|
|
|
467
475
|
*/
|
|
468
476
|
let opened = false;
|
|
469
477
|
try {
|
|
470
|
-
await row.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
478
|
+
await row.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
479
|
+
// Briefly: a hover the sidebar takes waits out the page's whole timeout otherwise.
|
|
480
|
+
await row.hover({ timeout: 2_000 }).catch(() => undefined);
|
|
481
|
+
/*
|
|
482
|
+
* The chat's own overflow button, by the chat id it carries; through the row only in a build without
|
|
483
|
+
* that attribute. A mouse click on it was taken by the sidebar over it — the chat's link, the sticky
|
|
484
|
+
* section header — and Playwright retried for the whole minute (live 2026-10-05), so `press` clicks
|
|
485
|
+
* the element itself when the mouse cannot reach it.
|
|
486
|
+
*/
|
|
487
|
+
const byChat = this.p.locator(Rename.moreButtonForChat(chatId)).first();
|
|
488
|
+
const more = (await byChat.count()) > 0
|
|
489
|
+
? byChat
|
|
490
|
+
: row.locator('xpath=ancestor-or-self::*[self::li or self::div][1]').getByRole('button', { name: Rename.moreButtonLabel, exact: true }).first();
|
|
491
|
+
await more.waitFor({ state: 'attached', timeout: 5_000 });
|
|
492
|
+
await this.press(more);
|
|
477
493
|
opened = true;
|
|
478
494
|
const item = this.p.getByRole('menuitem', { name: Rename.menuItem, exact: true }).first();
|
|
479
495
|
await item.waitFor({ state: 'visible', timeout: 5_000 });
|
|
480
|
-
await
|
|
496
|
+
await this.press(item);
|
|
481
497
|
opened = false;
|
|
482
498
|
const input = this.p.locator(Rename.input);
|
|
483
499
|
await input.waitFor({ state: 'visible', timeout: 10_000 });
|
|
484
|
-
|
|
485
|
-
await
|
|
500
|
+
const wanted = name.slice(0, Rename.maxLength);
|
|
501
|
+
await input.fill(wanted);
|
|
502
|
+
await this.press(this.p.getByRole('button', { name: Rename.saveText, exact: true }).first());
|
|
486
503
|
await input.waitFor({ state: 'detached', timeout: 10_000 }).catch(() => undefined);
|
|
504
|
+
// Named only when the sidebar says so: its row's label is the chat's name.
|
|
505
|
+
const shows = async () => ((await row.getAttribute('aria-label').catch(() => null)) ?? '').trim();
|
|
506
|
+
const deadline = Date.now() + 8_000;
|
|
507
|
+
while ((await shows()) !== wanted && Date.now() < deadline)
|
|
508
|
+
await this.p.waitForTimeout(250);
|
|
509
|
+
if ((await shows()) !== wanted) {
|
|
510
|
+
this.emit('chat-name-failed', { chatId, name, error: `the sidebar still shows "${await shows()}"` });
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
487
513
|
this.emit('chat-named', { chatId, name });
|
|
488
514
|
return true;
|
|
489
515
|
}
|
|
@@ -498,6 +524,19 @@ Current URL: ${url}`);
|
|
|
498
524
|
await this.dismissOpenMenu();
|
|
499
525
|
}
|
|
500
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* Clicks an element: with the mouse when it can reach it, else on the element itself. A short wait
|
|
529
|
+
* for the mouse, not the page's minute: in the sidebar another element lies over the target and
|
|
530
|
+
* takes the click, which no amount of retrying changes.
|
|
531
|
+
*/
|
|
532
|
+
async press(target) {
|
|
533
|
+
try {
|
|
534
|
+
await target.click({ timeout: 3_000 });
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
await target.evaluate((el) => el.click(), undefined, { timeout: 5_000 });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
501
540
|
/**
|
|
502
541
|
* Closes whatever menu or dialog is open, without caring which one it is.
|
|
503
542
|
*
|
|
@@ -567,9 +606,14 @@ Current URL: ${url}`);
|
|
|
567
606
|
* Opens the picker and reads what it offers, then closes it again without choosing.
|
|
568
607
|
*
|
|
569
608
|
* The list is whatever this tenant shows today. Nothing is filtered and nothing is
|
|
570
|
-
* translated: a name shown here is the name the chat uses
|
|
571
|
-
*
|
|
572
|
-
*
|
|
609
|
+
* translated: a name shown here is the name the chat uses. `raw` keeps the option's whole text,
|
|
610
|
+
* which is where Microsoft puts the one-line description and any "limit reached" notice.
|
|
611
|
+
*
|
|
612
|
+
* Each option comes with its `locator`: where it sits in the picker, read here and used to choose
|
|
613
|
+
* it again at the start of a run (see `selectModel`). A group is known by its test id and its place,
|
|
614
|
+
* not by its text: once a model of a group is chosen, Copilot writes that model's name on the
|
|
615
|
+
* group's row ("GPT" reads "GPT-5.6 Sol Think deeper"), and read by text the group lost that very
|
|
616
|
+
* model, so the run could not choose it and went on Auto (2026-10-05).
|
|
573
617
|
*/
|
|
574
618
|
async listModels() {
|
|
575
619
|
const button = await this.resolveModelButton();
|
|
@@ -585,16 +629,18 @@ Current URL: ${url}`);
|
|
|
585
629
|
const top = await this.readMenuRows();
|
|
586
630
|
await this.captureMenu('top', top);
|
|
587
631
|
await this.closeMenu();
|
|
588
|
-
const
|
|
632
|
+
const strip = (r) => ({ name: r.name, raw: r.raw, selected: r.selected, disabled: r.disabled, role: r.role });
|
|
633
|
+
const options = top.filter((r) => !r.opensSubmenu).map((r) => ({ ...strip(r), locator: { role: r.role, index: r.index } }));
|
|
589
634
|
const groups = top.filter((r) => r.opensSubmenu);
|
|
590
|
-
const topNames = new Set(top.map((r) => r.name.toLowerCase()));
|
|
591
635
|
// Each group is opened in its own pass, from a freshly opened menu. Walking several
|
|
592
636
|
// submenus in one pass works until one of them closes the one above it, and then the
|
|
593
637
|
// reader silently returns half a list; re-opening costs a second and cannot go wrong.
|
|
594
638
|
for (const group of groups) {
|
|
595
|
-
const
|
|
639
|
+
const where = { testId: group.testId || undefined, index: group.index, name: group.name };
|
|
640
|
+
const children = await this.readSubmenu(button, where);
|
|
641
|
+
const name = groupLabel(group, children);
|
|
596
642
|
for (const child of children) {
|
|
597
|
-
options.push({ ...child, group:
|
|
643
|
+
options.push({ ...strip(child), group: name, groupRaw: group.raw, locator: { role: child.role, index: child.index, group: { ...where, name } } });
|
|
598
644
|
}
|
|
599
645
|
}
|
|
600
646
|
this.emit('models-read', { count: options.length, groups: groups.length, current });
|
|
@@ -636,36 +682,59 @@ Current URL: ${url}`);
|
|
|
636
682
|
/* a diagnostic, never the reason a reading fails */
|
|
637
683
|
}
|
|
638
684
|
}
|
|
639
|
-
/**
|
|
685
|
+
/**
|
|
686
|
+
* Every visible menu row right now, across the menu and whatever submenu is open, in page order.
|
|
687
|
+
*
|
|
688
|
+
* Each row says which menu it is in (`menuOf`, the menu's `aria-labelledby`: a submenu is labelled by
|
|
689
|
+
* the row that opens it) and its place there (`index`), so the same row can be found again without
|
|
690
|
+
* going by its text. Rows are not merged by name: the group row and the model chosen in it carry the
|
|
691
|
+
* same name once that model is in force.
|
|
692
|
+
*/
|
|
640
693
|
async readMenuRows() {
|
|
641
694
|
return await this.p.evaluate(({ roles, selectedAttrs, submenuAttrs }) => {
|
|
642
|
-
const seen = new Set();
|
|
643
695
|
const out = [];
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
|
|
662
|
-
role,
|
|
663
|
-
opensSubmenu: submenuAttrs.some((a) => {
|
|
664
|
-
const v = el.getAttribute(a);
|
|
665
|
-
return a === 'aria-haspopup' ? v === 'menu' || v === 'true' : v !== null;
|
|
666
|
-
}),
|
|
667
|
-
});
|
|
696
|
+
const selector = roles.map((r) => `[role="${r}"]`).join(',');
|
|
697
|
+
const counted = new Map();
|
|
698
|
+
// A menu's key: what labels it (a submenu is labelled by the row that opens it), else a mark put
|
|
699
|
+
// on it here, so a menu with no label is told apart from the others all the same.
|
|
700
|
+
// (Written inline: a named function in here is wrapped by the bundler in a helper the page does not have.)
|
|
701
|
+
const w = window;
|
|
702
|
+
for (const el of Array.from(document.querySelectorAll(selector))) {
|
|
703
|
+
// A menu that is closed is still in the DOM, so visibility is what separates
|
|
704
|
+
// what is on screen from what was on screen a moment ago.
|
|
705
|
+
if (el.getClientRects().length === 0)
|
|
706
|
+
continue;
|
|
707
|
+
const menu = el.closest('[role="menu"],[role="listbox"]');
|
|
708
|
+
const index = counted.get(menu) ?? 0;
|
|
709
|
+
counted.set(menu, index + 1);
|
|
710
|
+
if (menu && !menu.getAttribute('aria-labelledby') && !menu.getAttribute('data-cop-menu')) {
|
|
711
|
+
w.__copMenus = (w.__copMenus ?? 0) + 1;
|
|
712
|
+
menu.setAttribute('data-cop-menu', `cop-menu-${w.__copMenus}`);
|
|
668
713
|
}
|
|
714
|
+
const menuOf = menu ? (menu.getAttribute('aria-labelledby') || menu.getAttribute('data-cop-menu') || '') : '';
|
|
715
|
+
// Marked, so the row can be pointed at in this page without its text.
|
|
716
|
+
el.setAttribute('data-cop-row', `${menuOf}#${index}`);
|
|
717
|
+
const raw = (el.innerText || '').trim();
|
|
718
|
+
const label = el.getAttribute('aria-label')?.trim() ?? '';
|
|
719
|
+
// The first line is the name; the description sits underneath it.
|
|
720
|
+
const name = (raw.split('\n')[0] || label || '').trim();
|
|
721
|
+
if (!name)
|
|
722
|
+
continue;
|
|
723
|
+
out.push({
|
|
724
|
+
name,
|
|
725
|
+
raw,
|
|
726
|
+
selected: selectedAttrs.some((a) => el.getAttribute(a) === 'true'),
|
|
727
|
+
disabled: el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('disabled'),
|
|
728
|
+
role: el.getAttribute('role') ?? '',
|
|
729
|
+
opensSubmenu: submenuAttrs.some((a) => {
|
|
730
|
+
const v = el.getAttribute(a);
|
|
731
|
+
return a === 'aria-haspopup' ? v === 'menu' || v === 'true' : v !== null;
|
|
732
|
+
}),
|
|
733
|
+
index,
|
|
734
|
+
menuOf,
|
|
735
|
+
testId: el.getAttribute('data-test-id') ?? el.getAttribute('data-testid') ?? '',
|
|
736
|
+
elId: el.id ?? '',
|
|
737
|
+
});
|
|
669
738
|
}
|
|
670
739
|
return out;
|
|
671
740
|
}, {
|
|
@@ -674,76 +743,130 @@ Current URL: ${url}`);
|
|
|
674
743
|
submenuAttrs: [...Model.submenuAttributes],
|
|
675
744
|
});
|
|
676
745
|
}
|
|
746
|
+
/** The rows of the top menu, read while only it is open: the menu its first row is in. */
|
|
747
|
+
topRows(rows) {
|
|
748
|
+
const key = rows[0]?.menuOf;
|
|
749
|
+
return key === undefined ? [] : rows.filter((r) => r.menuOf === key);
|
|
750
|
+
}
|
|
677
751
|
/**
|
|
678
|
-
* Opens one group and
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
* popup container is which, which is Fluent's business and changes with its internals.
|
|
752
|
+
* Opens one group of the open picker and returns the key of its submenu (see `readMenuRows`), or null.
|
|
753
|
+
* The group's row is found by its test id, else by its place among the top rows, else by its text. Its
|
|
754
|
+
* submenu is the menu labelled by that row, or, in a build that does not label it, the menu that
|
|
755
|
+
* appeared when the row was hovered.
|
|
683
756
|
*/
|
|
684
|
-
async
|
|
757
|
+
async openGroup(group) {
|
|
758
|
+
const top = this.topRows(await this.readMenuRows());
|
|
759
|
+
const row = (group.testId ? top.find((r) => r.opensSubmenu && r.testId === group.testId) : undefined) ??
|
|
760
|
+
top.find((r) => r.opensSubmenu && r.index === group.index) ??
|
|
761
|
+
top.find((r) => r.opensSubmenu && sameModel(r.name, group.name));
|
|
762
|
+
if (!row)
|
|
763
|
+
return null;
|
|
764
|
+
const trigger = this.p.locator(`[data-cop-row="${row.menuOf}#${row.index}"]`).first();
|
|
765
|
+
const opened = async (ms) => {
|
|
766
|
+
const deadline = Date.now() + ms;
|
|
767
|
+
do {
|
|
768
|
+
const rows = (await this.readMenuRows()).filter((r) => r.menuOf !== row.menuOf);
|
|
769
|
+
const own = rows.find((r) => row.elId && r.menuOf === row.elId) ?? rows[0];
|
|
770
|
+
if (own)
|
|
771
|
+
return own.menuOf;
|
|
772
|
+
await this.p.waitForTimeout(200);
|
|
773
|
+
} while (Date.now() < deadline);
|
|
774
|
+
return null;
|
|
775
|
+
};
|
|
776
|
+
// Hover is how these open; a click is the fallback for a build that wants one.
|
|
777
|
+
await trigger.hover().catch(() => undefined);
|
|
778
|
+
const hovered = await opened(4_000);
|
|
779
|
+
if (hovered)
|
|
780
|
+
return hovered;
|
|
781
|
+
await trigger.click().catch(() => undefined);
|
|
782
|
+
return await opened(4_000);
|
|
783
|
+
}
|
|
784
|
+
/** Opens a fresh menu, then the group, and reads what is in it. */
|
|
785
|
+
async readSubmenu(button, group) {
|
|
685
786
|
await button.click();
|
|
686
787
|
if (!(await this.waitForPopup()))
|
|
687
788
|
return [];
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return [];
|
|
692
|
-
}
|
|
693
|
-
// Hover is how these open; a click is the fallback for a build that wants one.
|
|
694
|
-
await trigger.hover().catch(() => undefined);
|
|
695
|
-
let children = await this.waitForNewRows(topNames);
|
|
696
|
-
if (children.length === 0) {
|
|
697
|
-
await trigger.click().catch(() => undefined);
|
|
698
|
-
children = await this.waitForNewRows(topNames);
|
|
699
|
-
}
|
|
700
|
-
await this.captureMenu(`group-${groupName.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
|
|
789
|
+
const key = await this.openGroup(group);
|
|
790
|
+
const children = key ? (await this.readMenuRows()).filter((r) => r.menuOf === key) : [];
|
|
791
|
+
await this.captureMenu(`group-${group.name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, children);
|
|
701
792
|
await this.closeMenu();
|
|
702
793
|
return children;
|
|
703
794
|
}
|
|
704
|
-
/**
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
795
|
+
/**
|
|
796
|
+
* The row a locator points at, in a picker this opens (the group included); the menu is left open.
|
|
797
|
+
* The row must still carry the name: a place that now holds another model is not taken, and a model
|
|
798
|
+
* that moved within its menu is found there by name.
|
|
799
|
+
*/
|
|
800
|
+
async openAtLocator(name, locator) {
|
|
801
|
+
const button = await this.resolveModelButton();
|
|
802
|
+
if (!button)
|
|
803
|
+
return { problem: 'This chat does not show a model picker.' };
|
|
804
|
+
await button.click();
|
|
805
|
+
if (!(await this.waitForPopup()))
|
|
806
|
+
return { problem: 'The model picker did not open.' };
|
|
807
|
+
let rows;
|
|
808
|
+
if (locator.group) {
|
|
809
|
+
const key = await this.openGroup(locator.group);
|
|
810
|
+
if (!key)
|
|
811
|
+
return { problem: `the group "${locator.group.name}" of the picker did not open.` };
|
|
812
|
+
rows = (await this.readMenuRows()).filter((r) => r.menuOf === key);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
rows = this.topRows(await this.readMenuRows());
|
|
816
|
+
}
|
|
817
|
+
const choices = rows.filter((r) => !r.opensSubmenu);
|
|
818
|
+
const row = choices.find((r) => r.index === locator.index && sameModel(r.name, name)) ?? choices.find((r) => sameModel(r.name, name));
|
|
819
|
+
return row ? { row } : { problem: `"${name}" is not where the list was read${locator.group ? ` (in "${locator.group.name}")` : ''}.` };
|
|
714
820
|
}
|
|
715
|
-
/**
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
821
|
+
/** Clicks one row of the open picker, on the element itself: a mouse moving across the menu re-renders the submenus under it. */
|
|
822
|
+
async clickRow(row) {
|
|
823
|
+
return await this.p
|
|
824
|
+
.evaluate((mark) => {
|
|
825
|
+
const el = document.querySelector(`[data-cop-row="${mark}"]`);
|
|
826
|
+
if (!el || el.getClientRects().length === 0)
|
|
827
|
+
return false;
|
|
828
|
+
el.click();
|
|
829
|
+
return true;
|
|
830
|
+
}, `${row.menuOf}#${row.index}`)
|
|
831
|
+
.catch(() => false);
|
|
719
832
|
}
|
|
720
833
|
/**
|
|
721
|
-
* Chooses
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
* A mouse click first travels to the row, and on the way it crosses the other rows of the menu
|
|
725
|
-
* above; Fluent opens or re-renders their submenus as the pointer passes, the row being aimed at
|
|
726
|
-
* is replaced, and Playwright starts again — "element is not stable", "element was detached from
|
|
727
|
-
* the DOM, retrying" — for as long as the page's default timeout allows. Live, that was 38
|
|
728
|
-
* seconds of a window stuck on the model menu with "GPT 5.6 Think deeper" in plain sight, until
|
|
729
|
-
* the window was closed and the task failed with "Target page, context or browser has been
|
|
730
|
-
* closed". A click dispatched on the element does not move the pointer, so nothing closes under
|
|
731
|
-
* it.
|
|
732
|
-
*
|
|
733
|
-
* Either way the attempt is short: the caller reads the model button back to decide whether the
|
|
734
|
-
* choice took, so a click that did not land costs seconds and says so, rather than a minute.
|
|
834
|
+
* Chooses a model at its locator and reads its row again from a fresh menu: chosen only when the
|
|
835
|
+
* row itself is marked (`aria-checked`). The button's text is no proof — it is shortened, and it is
|
|
836
|
+
* the same for a model and its group.
|
|
735
837
|
*/
|
|
736
|
-
async
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
838
|
+
async chooseAt(name, locator) {
|
|
839
|
+
const before = await this.currentModel();
|
|
840
|
+
const found = await this.openAtLocator(name, locator);
|
|
841
|
+
if ('problem' in found) {
|
|
842
|
+
await this.closeMenu();
|
|
843
|
+
return { ok: false, current: before, reason: found.problem };
|
|
844
|
+
}
|
|
845
|
+
if (found.row.disabled) {
|
|
846
|
+
await this.closeMenu();
|
|
847
|
+
return { ok: false, current: before, reason: `"${found.row.name}" is shown but not available right now.` };
|
|
848
|
+
}
|
|
849
|
+
if (found.row.selected) {
|
|
850
|
+
await this.closeMenu();
|
|
851
|
+
this.emit('model-already-selected', { model: found.row.name, buttonShows: before, by: 'locator' });
|
|
852
|
+
return { ok: true, current: found.row.name, by: 'locator' };
|
|
853
|
+
}
|
|
854
|
+
const clicked = await this.clickRow(found.row);
|
|
855
|
+
await this.p.waitForTimeout(800);
|
|
856
|
+
// Whatever the click did, the menu is not left open over the chat.
|
|
857
|
+
await this.closeMenu();
|
|
858
|
+
const again = clicked ? await this.openAtLocator(found.row.name, locator) : { problem: 'the row could not be clicked.' };
|
|
859
|
+
await this.closeMenu();
|
|
860
|
+
const after = await this.currentModel();
|
|
861
|
+
if ('row' in again && again.row.selected) {
|
|
862
|
+
this.emit('model-selected', { model: again.row.name, buttonShows: after, by: 'locator' });
|
|
863
|
+
return { ok: true, current: again.row.name, by: 'locator' };
|
|
745
864
|
}
|
|
746
|
-
|
|
865
|
+
return {
|
|
866
|
+
ok: false,
|
|
867
|
+
current: after,
|
|
868
|
+
reason: 'problem' in again ? `after choosing "${name}", ${again.problem}` : `"${name}" was clicked, but the picker does not mark it as chosen (the button shows "${after ?? 'nothing'}").`,
|
|
869
|
+
};
|
|
747
870
|
}
|
|
748
871
|
/**
|
|
749
872
|
* Whether the model picker is open: its button says so (`aria-expanded`), or its rows are on screen.
|
|
@@ -835,107 +958,45 @@ Current URL: ${url}`);
|
|
|
835
958
|
return null;
|
|
836
959
|
}
|
|
837
960
|
/**
|
|
838
|
-
* Sets the chat to a model
|
|
961
|
+
* Sets the chat to a model.
|
|
839
962
|
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
963
|
+
* With a `locator` from the list the operator read (see `listModels`), the row is gone to directly.
|
|
964
|
+
* Without one, or when the page moved it, the line-up is read again with fresh locators and the model
|
|
965
|
+
* is found there by name — or under its new name when the page renamed it (see `modelMatch.ts`). The
|
|
966
|
+
* choice counts only when the picker marks the row as chosen, read again from a fresh menu, because a
|
|
967
|
+
* menu item that is out of quota can look clicked and change nothing; when it did not take, the
|
|
968
|
+
* caller is told what the chat is actually on.
|
|
843
969
|
*/
|
|
844
|
-
async selectModel(name) {
|
|
970
|
+
async selectModel(name, opts = {}) {
|
|
845
971
|
const button = await this.resolveModelButton();
|
|
846
972
|
if (!button)
|
|
847
973
|
return { ok: false, current: null, reason: 'This chat does not show a model picker.' };
|
|
848
|
-
|
|
849
|
-
if (
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
return { ok: false, current: before, reason: 'The model picker did not open.' };
|
|
854
|
-
// The menu is walked rather than replayed from a saved path. A cached path goes stale the
|
|
855
|
-
// moment Microsoft moves a model between groups, and a name is what the user chose.
|
|
856
|
-
const top = await this.readMenuRows();
|
|
857
|
-
// As the page writes it today: "GPT-5.6" and "GPT 5.6" are one model (see `modelMatch.ts`).
|
|
858
|
-
const wanted = (r) => sameModel(r.name, name);
|
|
859
|
-
let target = top.find((r) => wanted(r) && !r.opensSubmenu);
|
|
860
|
-
const inSubmenu = !target;
|
|
861
|
-
if (!target) {
|
|
862
|
-
const topNames = new Set(top.map((r) => r.name.toLowerCase()));
|
|
863
|
-
for (const group of top.filter((r) => r.opensSubmenu)) {
|
|
864
|
-
const trigger = this.menuRow(group.name);
|
|
865
|
-
await trigger.hover().catch(() => undefined);
|
|
866
|
-
let children = await this.waitForNewRows(topNames);
|
|
867
|
-
if (children.length === 0) {
|
|
868
|
-
await trigger.click().catch(() => undefined);
|
|
869
|
-
children = await this.waitForNewRows(topNames);
|
|
870
|
-
}
|
|
871
|
-
const found = children.find(wanted);
|
|
872
|
-
if (found) {
|
|
873
|
-
target = found;
|
|
874
|
-
break;
|
|
875
|
-
}
|
|
876
|
-
}
|
|
974
|
+
let first = null;
|
|
975
|
+
if (opts.locator) {
|
|
976
|
+
first = await this.chooseAt(name, opts.locator);
|
|
977
|
+
if (first.ok)
|
|
978
|
+
return first;
|
|
877
979
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
* so the saved catalogue follows the page (live run 2026-10-04: "GPT 5.6 Think deeper" had become
|
|
884
|
-
* "GPT 5.6 Sol Think deeper", and every run went on the default).
|
|
885
|
-
*/
|
|
886
|
-
const read = await this.listModels().catch(() => null);
|
|
887
|
-
const options = read?.options ?? [];
|
|
888
|
-
const match = pageModelFor(name, options);
|
|
889
|
-
if (match && !sameModel(match.name, name)) {
|
|
890
|
-
const again = await this.selectModel(match.name);
|
|
891
|
-
return { ...again, matched: match.name, options };
|
|
892
|
-
}
|
|
980
|
+
// The line-up as the page shows it now, each model with its place in it.
|
|
981
|
+
const read = await this.listModels().catch(() => null);
|
|
982
|
+
const options = read?.options ?? [];
|
|
983
|
+
const match = options.find((o) => sameModel(o.name, name)) ?? pageModelFor(name, options);
|
|
984
|
+
if (!match?.locator) {
|
|
893
985
|
const offered = options.filter((o) => !o.disabled).map((o) => o.name);
|
|
894
986
|
return {
|
|
895
987
|
ok: false,
|
|
896
|
-
current:
|
|
897
|
-
reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}
|
|
988
|
+
current: await this.currentModel(),
|
|
989
|
+
reason: `The picker does not offer "${name}" any more${offered.length > 0 ? `; it offers ${offered.map((o) => `"${o}"`).join(', ')}` : ''}.` +
|
|
990
|
+
(first?.reason ? ` (At the place the list was read: ${first.reason})` : ''),
|
|
898
991
|
...(options.length > 0 ? { options } : {}),
|
|
899
992
|
};
|
|
900
993
|
}
|
|
901
|
-
|
|
902
|
-
* Already the one in force: close the menu and say so, without clicking.
|
|
903
|
-
*
|
|
904
|
-
* The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
|
|
905
|
-
* reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
|
|
906
|
-
* model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
|
|
907
|
-
* click it used to get did nothing in Copilot: the row already in force ignores it, the menu
|
|
908
|
-
* stayed open over the composer, and the run went on with the model list hanging on screen.
|
|
909
|
-
*/
|
|
910
|
-
if (target.selected) {
|
|
911
|
-
await this.closeMenu();
|
|
912
|
-
this.emit('model-already-selected', { model: target.name, buttonShows: before });
|
|
913
|
-
return { ok: true, current: target.name };
|
|
914
|
-
}
|
|
915
|
-
if (target.disabled) {
|
|
916
|
-
await this.closeMenu();
|
|
917
|
-
return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
|
|
918
|
-
}
|
|
919
|
-
await this.clickMenuRow(target.name, inSubmenu);
|
|
920
|
-
await this.p.waitForTimeout(1_000);
|
|
921
|
-
// Whatever the click did, the menu is not left open over the chat: a build that keeps it open
|
|
922
|
-
// after a choice, or a click that did not land, would otherwise leave it for the next message.
|
|
923
|
-
await this.closeMenu();
|
|
924
|
-
const after = await this.currentModel();
|
|
925
|
-
// The button shows the choice, so it is the check. It also **shortens** it: picking
|
|
926
|
-
// "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So the shown value
|
|
927
|
-
// agrees when it is the row's name with words dropped from its end, and the row's full name
|
|
928
|
-
// is what gets reported back, because that is what was chosen and what will be asked for
|
|
929
|
-
// next time. A piece from anywhere used to count: "Think deeper" on the button passed for
|
|
930
|
-
// "GPT-5.6 Sol Think deeper" (2026-10-05), and the run said it was on a model it was not.
|
|
931
|
-
if (buttonShows(after, target.name)) {
|
|
932
|
-
this.emit('model-selected', { model: target.name, buttonShows: after });
|
|
933
|
-
return { ok: true, current: target.name };
|
|
934
|
-
}
|
|
994
|
+
const chosen = await this.chooseAt(match.name, match.locator);
|
|
935
995
|
return {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
996
|
+
...chosen,
|
|
997
|
+
...(chosen.ok && !sameModel(match.name, name) ? { matched: match.name } : {}),
|
|
998
|
+
...(chosen.ok ? { by: opts.locator ? 'reread' : 'name' } : {}),
|
|
999
|
+
options,
|
|
939
1000
|
};
|
|
940
1001
|
}
|
|
941
1002
|
/**
|
|
@@ -241,6 +241,8 @@ export const Url = {
|
|
|
241
241
|
export const Rename = {
|
|
242
242
|
/** Per-chat overflow button in the sidebar. There is one per chat, so scope it to the row. */
|
|
243
243
|
moreButtonLabel: 'More',
|
|
244
|
+
/** The same button by the chat it belongs to (seen live 2026-10-05): found without going through the row. */
|
|
245
|
+
moreButtonForChat: (chatId) => `button[data-chat-history-more-button-conversation-id="${chatId}"]`,
|
|
244
246
|
menuItem: 'Rename',
|
|
245
247
|
/** The dialog's text input. */
|
|
246
248
|
input: '#new-session-name',
|