Spaces:
Running
Running
File size: 69,671 Bytes
8d0110a 6dbae4c 796777c 3ad88a4 43d2f79 fb81cea 5ba1394 07a9968 8625087 796777c db68f24 4734880 db68f24 4734880 db68f24 796777c 8d0110a 8625087 8d0110a 8625087 8d0110a 8625087 8d0110a 796777c c9c783c 796777c 3ad88a4 796777c 3ad88a4 796777c 3ad88a4 43d2f79 97d4a14 3ad88a4 07a9968 5ba1394 39a37e3 796777c c0f8586 1c022be 3ad88a4 60ca7ef 3ad88a4 8625087 3ad88a4 8625087 3ad88a4 8625087 3ad88a4 c0f8586 3ad88a4 8625087 3ad88a4 8625087 3ad88a4 34ceb36 8625087 3ad88a4 8625087 3ad88a4 8625087 3ad88a4 8625087 43d2f79 3ad88a4 8625087 3ad88a4 34ceb36 8625087 3ad88a4 8625087 3ad88a4 ea8edd8 fb8d4ff 3ad88a4 fb8d4ff 3ad88a4 fb8d4ff ea8edd8 796777c 3ad88a4 a64c4c4 796777c c9c783c 796777c 3ad88a4 796777c 3ad88a4 c9c783c 796777c 3ad88a4 796777c 39a37e3 1c022be 796777c 3ad88a4 796777c 3ad88a4 796777c 3ad88a4 8625087 3ad88a4 8625087 3ad88a4 8625087 3ad88a4 796777c 6dbae4c 3ad88a4 796777c 60ca7ef 796777c 60ca7ef 796777c 3ad88a4 6888bb5 796777c 3ad88a4 796777c 6888bb5 3ad88a4 6888bb5 3ad88a4 6888bb5 3ad88a4 60ca7ef 3ad88a4 6888bb5 a64c4c4 c0f8586 3ad88a4 6888bb5 c0d33e7 6888bb5 3ad88a4 1c022be 3ad88a4 6888bb5 1c022be 6888bb5 a64c4c4 6888bb5 3ad88a4 6888bb5 1c022be 3ad88a4 1c022be 3ad88a4 6888bb5 3ad88a4 6888bb5 3ad88a4 6888bb5 3ad88a4 6888bb5 796777c 39a37e3 796777c 39a37e3 796777c 3ad88a4 8625087 3ad88a4 796777c 34ceb36 796777c 8625087 796777c 3ad88a4 796777c 3ad88a4 796777c fb81cea a64c4c4 fb81cea a64c4c4 fb81cea 5ba1394 fb81cea 5ba1394 72925a7 fb81cea 72925a7 fb81cea 5ba1394 fb81cea 5ba1394 72925a7 5ba1394 72925a7 5ba1394 fb81cea 796777c 07a9968 fb81cea 1a88544 97d4a14 796777c c9c783c 796777c 43d2f79 796777c 6888bb5 3ad88a4 97d4a14 fb81cea 3ad88a4 43d2f79 5ba1394 796777c 5ba1394 796777c 85891ac 796777c 85891ac 3ad88a4 a64c4c4 3ad88a4 85891ac a64c4c4 23d5837 43d2f79 23d5837 43d2f79 3ad88a4 23d5837 85891ac 23d5837 3ad88a4 23d5837 796777c 43d2f79 a64c4c4 43d2f79 ee8e66a 43d2f79 3ad88a4 8887389 43d2f79 8887389 07a9968 8887389 796777c 23d5837 43d2f79 23d5837 fb81cea 5ba1394 fb81cea 5ba1394 fb81cea 48ed678 a64c4c4 3ad88a4 db68f24 3ad88a4 34ceb36 db68f24 ee8e66a db68f24 3ad88a4 34ceb36 db68f24 ee8e66a db68f24 3ad88a4 402be81 23d5837 fb81cea 23d5837 97d4a14 3ad88a4 1c022be 3ad88a4 23d5837 fb81cea 23d5837 39a37e3 402be81 3ad88a4 402be81 39a37e3 402be81 39a37e3 402be81 3ad88a4 402be81 3ad88a4 402be81 3ad88a4 402be81 c0f8586 3ad88a4 c0f8586 fb8d4ff 23d5837 fb81cea 23d5837 796777c 43d2f79 796777c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 | import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import posthog from "posthog-js";
import Sidebar from "./components/Sidebar";
import Message from "./components/Message";
import DiagramView from "./components/DiagramView";
import ReadmeView from "./components/ReadmeView";
import LandingHero from "./components/LandingHero";
import LandingIngestion from "./components/LandingIngestion";
import CustomCursor from "./components/CustomCursor";
import { fetchRepos, streamQuery, streamAgentQuery, fetchMcpStatus, fetchMcpPrompt, fetchAgentModels, fetchSessions, fetchSession, saveSession, deleteSession } from "./api";
// ββ Suggestion card icons ββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Simple 16Γ16 line-art SVGs for each suggestion category.
// Kept inline so there's no icon-library dependency.
// Clean Octicons-inspired icons β 16Γ16 filled/stroked, consistent 1.5px stroke
const ICONS = {
// Suggestion card icons
architecture: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.75 3.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM3.75 11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM13.75 11.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM8 5.5a.5.5 0 0 0-.5.5v1.5H5.06A2.25 2.25 0 1 0 5 9.25h.06l.44.44V11a2.25 2.25 0 1 0 1.5.04V9.69l.44-.44H11a2.25 2.25 0 1 0-.06-1.5H8.5V6a.5.5 0 0 0-.5-.5Z"/></svg>,
entry: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1h6.5c.966 0 1.75.784 1.75 1.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-6.5a.25.25 0 0 0-.25.25v11.5c0 .138.112.25.25.25h6.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 8.25 15h-6.5A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1Zm9.42 7.75-3.22 3.22a.75.75 0 1 1-1.06-1.06l1.97-1.97H3.75a.75.75 0 0 1 0-1.5h5.11L6.89 5.47a.75.75 0 1 1 1.06-1.06l3.22 3.22a.75.75 0 0 1 0 1.06Z"/></svg>,
classes: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M14.064 0h.186C15.216 0 16 .784 16 1.75v.186a8.752 8.752 0 0 1-2.564 6.186l-.458.459c-.314.314-.641.616-.979.904v3.207l-2.209 3.322A.75.75 0 0 1 9 15.75v-4.055c-.338-.288-.665-.59-.979-.904l-.458-.459A8.752 8.752 0 0 1 5 4.136V3.75A.75.75 0 0 1 5.75 3H9.5l-1.75 2h3.5l-1 2h2.25l1-4h.064ZM4.751 7.5H1a.75.75 0 0 0 0 1.5h2.37L4.75 7.5Zm.375 2.5L3.75 12.5H7l1.376-2.5H5.126Z"/></svg>,
flow: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11.28 12.78a.75.75 0 0 1-1.06-1.06l1.72-1.72H6.75a3.25 3.25 0 0 1-3.25-3.25v-3a.75.75 0 0 1 1.5 0v3a1.75 1.75 0 0 0 1.75 1.75h5.19l-1.72-1.72a.75.75 0 1 1 1.06-1.06l3 3a.75.75 0 0 1 0 1.06l-3 3Z"/></svg>,
functions: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4.72.22a.75.75 0 0 1 1.06 0L9.53 4 8.47 5.06 5.25 1.84 3.28 3.81l1.5 1.5a.75.75 0 0 1-1.06 1.06L2.22 4.87a.75.75 0 0 1 0-1.06L4.72.22ZM11.28 11.78a.75.75 0 0 1-1.06 0L6.47 8 7.53 6.94l3.22 3.22 1.97-1.97-1.5-1.5a.75.75 0 1 1 1.06-1.06l1.5 1.5a.75.75 0 0 1 0 1.06l-2.5 2.59ZM1.5 8.75h.69l.5-2H2a.75.75 0 0 1 0-1.5h1.19l.41-1.66a.75.75 0 1 1 1.46.36l-.33 1.3H6l.41-1.66a.75.75 0 1 1 1.46.36L7.5 5.25h.75a.75.75 0 0 1 0 1.5H7.19l-.5 2H8a.75.75 0 0 1 0 1.5H6.31l-.41 1.66a.75.75 0 1 1-1.46-.36l.33-1.3H3.5l-.41 1.66a.75.75 0 0 1-1.46-.36L2 8.75H1.5a.75.75 0 0 1 0-1.5h-.5Zm2.5 0h1.5l.5-2h-1.5l-.5 2Z"/></svg>,
diagram: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l1.22 1.22a.75.75 0 0 1-1.06 1.06L7.75 12.81l-1.47 1.47a.75.75 0 0 1-1.06-1.06L6.44 12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25H2.75Z"/></svg>,
shield: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M7.467.133a1.748 1.748 0 0 1 1.066 0l5.25 1.68A1.75 1.75 0 0 1 15 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.697 1.697 0 0 1-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 0 1 1.217-1.667Zm.61 1.429a.25.25 0 0 0-.153 0l-5.25 1.68a.25.25 0 0 0-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.196.196 0 0 0 .154 0c2.245-.956 3.582-2.104 4.366-3.298C13.225 9.666 13.5 8.36 13.5 7V3.48a.25.25 0 0 0-.174-.237l-5.25-1.68ZM11.28 6.28l-3.5 3.5a.75.75 0 0 1-1.06 0l-1.5-1.5a.75.75 0 0 1 1.06-1.06l.97.97 2.97-2.97a.75.75 0 0 1 1.06 1.06Z"/></svg>,
package: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8.878.392a1.75 1.75 0 0 0-1.756 0l-5.25 3.045A1.75 1.75 0 0 0 1 4.951v6.098c0 .624.332 1.2.872 1.514l5.25 3.045a1.75 1.75 0 0 0 1.756 0l5.25-3.045c.54-.313.872-.89.872-1.514V4.951c0-.624-.332-1.2-.872-1.514Zm-.438 1.297a.25.25 0 0 1 .25 0l2.688 1.559-4.003 2.32-2.929-1.71Zm.31 4.171v5.058l-4.25-2.464V5.745Zm1.5 5.058V5.745l4.25-2.464v5.07Z"/></svg>,
compare: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 0 1-1.06-1.06l1.97-1.97H5.75a3.25 3.25 0 0 1-3.25-3.25v-3a.75.75 0 0 1 1.5 0v3a1.75 1.75 0 0 0 1.75 1.75h4.94l-1.97-1.97a.75.75 0 1 1 1.06-1.06l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25Z"/></svg>,
complexity: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 1.75V13.5h13.75a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75V1.75a.75.75 0 0 1 1.5 0Zm14.28 2.53-5.25 5.25a.75.75 0 0 1-1.06 0L7 7.06 4.28 9.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0L9.97 7.94l4.72-4.72a.75.75 0 1 1 1.06 1.06Z"/></svg>,
config: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.103-.303c-.066-.019-.176-.011-.299.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.103.303c.066.019.176.011.299-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.531.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/></svg>,
pattern: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 6h-1.5A1.75 1.75 0 0 1 1 4.25Zm1.75-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM1 11.75C1 10.784 1.784 10 2.75 10h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 15h-1.5A1.75 1.75 0 0 1 1 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25Zm5.5-9.5C8.25 1 9.034 1.784 9.034 2.75v1.5a1.75 1.75 0 0 1-1.75 1.75h-1.5A1.75 1.75 0 0 1 4.034 4.25v-1.5C4.034 1.784 4.818 1 5.784 1h1.5v.25ZM8.25 1h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 9.75 6h-1.5A1.75 1.75 0 0 1 6.5 4.25v-1.5C6.5 1.784 7.284 1 8.25 1Zm.25 1.5a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25Zm3.25-.75c0-.966.784-1.75 1.75-1.75h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 15 6h-1.5a1.75 1.75 0 0 1-1.75-1.75Zm1.75-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25H15a.25.25 0 0 0 .25-.25v-1.5A.25.25 0 0 0 15 1.5Zm-3.25 9.5a1.75 1.75 0 0 0-1.75 1.75v1.5c0 .966.784 1.75 1.75 1.75h1.5A1.75 1.75 0 0 0 15 13.25v-1.5A1.75 1.75 0 0 0 13.5 10Zm-.25 1.75a.25.25 0 0 1 .25-.25h1.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25h-1.5a.25.25 0 0 1-.25-.25Z"/></svg>,
// Onboarding step icons
github: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>,
chat: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>,
explore: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm3.94-1.5 2.656.886 1.316-1.316a.75.75 0 0 1 1.09 1.03l-.03.03-1.316 1.316.887 2.657a.75.75 0 0 1-.975.975l-4-2a.75.75 0 0 1-.31-.31l-2-4a.75.75 0 0 1 .975-.975l4 2Zm.16 2.11L4.5 9.5l1.89.63-.69-2.07Zm1.74.5.63 1.89 1.07-1.07-.63-1.89-.07.07-1 1Z"/></svg>,
};
export default function App() {
// ββ URL-driven state βββββββββββββββββββββββββββββββββββββββββββββββββ
// activeRepo + view used to be local React state; they're now derived
// from the URL so refreshing preserves position, links are shareable,
// and browser back/forward works without bespoke history shims. The
// setActiveRepo / setView functions wrap useNavigate so existing call
// sites don't change β they just push to history instead of mutating
// local state.
const params = useParams();
const navigate = useNavigate();
const location = useLocation();
const activeRepo = useMemo(() => {
return (params.owner && params.repo) ? `${params.owner}/${params.repo}` : null;
}, [params.owner, params.repo]);
// Session id from /r/:owner/:repo/c/:sessionId β drives which conversation
// is loaded into the chat panel. Null when the user is on a fresh chat.
const sessionIdFromUrl = params.sessionId || null;
// View is determined by the trailing path segment:
// /r/owner/repo β graph (default β diagram is the richer landing)
// /r/owner/repo/diagram β graph
// /r/owner/repo/chat β chat
// /r/owner/repo/c/:sessionId β chat (a session is always a chat)
// Without a repo, view is irrelevant; we report "chat" so the empty
// landing state stays unchanged.
const view = useMemo(() => {
if (!activeRepo) return "chat";
if (location.pathname.endsWith("/chat")) return "chat";
if (location.pathname.includes("/c/")) return "chat";
return "graph";
}, [activeRepo, location.pathname]);
const setActiveRepo = useCallback((slug) => {
if (!slug) navigate("/");
else navigate(`/r/${slug}`);
}, [navigate]);
const setView = useCallback((nextView) => {
if (!activeRepo) return; // no repo selected β nothing to switch on
if (nextView === "chat") navigate(`/r/${activeRepo}/chat`);
else navigate(`/r/${activeRepo}/diagram`);
}, [activeRepo, navigate]);
const [repos, setRepos] = useState([]);
const [reposLoading, setReposLoading] = useState(true);
const [mode, setMode] = useState("hybrid");
const [agentMode, setAgentMode] = useState(() => localStorage.getItem('ghrc_agentMode') === 'true');
const [messages, setMessages] = useState([]);
const [sessions, setSessions] = useState([]); // recent sessions for active repo
const [lastSources, setLastSources] = useState([]); // sources from last RAG query (kept for future use)
const [focusFiles, setFocusFiles] = useState(null); // filepaths from last "Diagram this β" click
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [backendOk, setBackendOk] = useState(null); // null=unknown, true=ok, false=error
const [currentSessionId, setCurrentSessionId] = useState(null); // highlights active session in sidebar
const prevRepoRef = useRef(null); // track previous repo before switching
const messagesRef = useRef([]); // always-fresh messages ref to avoid stale closures
const sessionIdRef = useRef(null); // ID of the current open session
const [showReadme, setShowReadme] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(
() => localStorage.getItem('ghrc_sidebarCollapsed') === 'true'
);
// Diagram view's fullscreen state is lifted here so the layout can hide the
// sidebar + chat-header chrome cleanly (rather than relying on z-index over
// elements that can create stacking/containing blocks mid-animation).
const [diagramFullscreen, setDiagramFullscreen] = useState(false);
// Active in-landing ingestion journey. When non-null, the hero is replaced
// by LandingIngestion which owns its own SSE stream and renders the live
// map forming. On completion/error we clear this and route into the new
// repo. Shape: { url: string, slug: string|null, accent: string }
const [activeJourney, setActiveJourney] = useState(null);
// Prompt autocomplete: shown when input starts with "/"
const [prompts, setPrompts] = useState([]); // MCP prompt list
const [promptMenu, setPromptMenu] = useState(false); // dropdown visible
const [promptFilter, setPromptFilter] = useState(""); // text after "/"
// Model selector: available models fetched from /agent/models
const [agentModels, setAgentModels] = useState([]);
const [selectedModelId, setSelectedModelId] = useState(
() => localStorage.getItem('ghrc_selectedModel') || null
);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const modelMenuRef = useRef(null);
const bottomRef = useRef(null);
const scrollRef = useRef(null);
const latestAssistantRef = useRef(null); // top of the current streaming assistant message
const textareaRef = useRef(null);
const stopStream = useRef(null); // cleanup fn for active SSE
const streamingRef = useRef(false); // always-fresh streaming flag for event handlers
const countdownTimer = useRef(null); // setInterval handle for rate-limit auto-retry
const handleSubmitRef = useRef(null); // stable ref so closures can call handleSubmit
const msgIdCounter = useRef(0); // monotonic counter for message IDs β avoids Date.now() collisions
const rateLimitRetries = useRef(0); // consecutive rate-limit count β resets on success
// ββ Multi-session persistence (localStorage, up to 10 sessions per repo) βββ
// Modelled on rag-research-copilot: each session has an id, title (first
// question truncated to 55 chars), messages array, and ISO timestamp.
// Sessions are stored as `ghrc_sessions_{repo}` β JSON array, newest first.
// Strip transient streaming fields before saving so reloaded messages are clean
function cleanMsgs(msgs) {
return msgs.map(({ streaming: _s, currentTool: _ct, phase: _p, ...m }) => m);
}
// Build the session record, mirror it into local state immediately, and
// persist to the backend in the background. Optimistic updates keep the
// sidebar responsive even on slow networks; a failed save logs but doesn't
// surface a UI error since the local state is already correct.
function upsertSession(repo, sessionId, msgs, isAgentMode = false) {
if (!repo || !sessionId || msgs.length === 0) return null;
const title = msgs.find(m => m.role === "user")?.content?.slice(0, 55) ?? "Untitled";
const session = {
id: sessionId,
repo,
title,
messages: cleanMsgs(msgs),
timestamp: new Date().toISOString(),
agentMode: isAgentMode,
};
setSessions(prev => {
const exists = prev.some(s => s.id === sessionId);
if (exists) return prev.map(s => s.id === sessionId ? session : s);
return [session, ...prev].slice(0, 50);
});
saveSession(session).catch(err => console.warn("session save failed:", err));
return session;
}
// Keep refs in sync so event handlers always read the latest values
useEffect(() => { messagesRef.current = messages; }, [messages]);
useEffect(() => { streamingRef.current = streaming; }, [streaming]);
// Persist agent mode preference across page loads
useEffect(() => { localStorage.setItem('ghrc_agentMode', agentMode); }, [agentMode]);
// Persist selected model
useEffect(() => {
if (selectedModelId) localStorage.setItem('ghrc_selectedModel', selectedModelId);
else localStorage.removeItem('ghrc_selectedModel');
}, [selectedModelId]);
// Fetch available agent models once on mount
useEffect(() => {
fetchAgentModels().then(models => {
setAgentModels(models);
// If no model selected yet, default to the first available one
setSelectedModelId(prev => {
if (prev && models.some(m => m.id === prev)) return prev;
const first = models.find(m => m.available);
return first ? first.id : null;
});
});
}, []);
// Close model menu when clicking outside
useEffect(() => {
function onClickOutside(e) {
if (modelMenuRef.current && !modelMenuRef.current.contains(e.target)) {
setModelMenuOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
// Keep handleSubmitRef pointing at the latest handleSubmit (avoids stale closures
// in the rate-limit countdown which captures this ref via closure).
// We update it on every render so it always has the current state in scope.
useEffect(() => { handleSubmitRef.current = (q) => handleSubmit(null, q); });
// Load sessions list whenever active repo changes. Sessions live in a
// backend Qdrant collection; the first time a user with pre-existing
// localStorage data hits the new app for a given repo we push those
// records up so nothing is lost in the transition.
useEffect(() => {
// Save the current session for the old repo before switching
if (prevRepoRef.current && prevRepoRef.current !== activeRepo && sessionIdRef.current) {
upsertSession(prevRepoRef.current, sessionIdRef.current, messagesRef.current, agentMode);
}
prevRepoRef.current = activeRepo;
// Reset chat state. sessionIdRef is set later by the sessionId-from-URL
// effect below if the user landed on /r/owner/repo/c/:id.
sessionIdRef.current = null;
setCurrentSessionId(null);
setMessages([]);
setLastSources([]);
setFocusFiles(null);
if (!activeRepo) {
setSessions([]);
return;
}
let cancelled = false;
(async () => {
// One-time migration: drain any localStorage records for this repo
// into the backend, then clear the local key. Idempotent β repeat
// calls are no-ops because the localStorage key is gone.
try {
const localKey = `ghrc_sessions_${activeRepo}`;
const localRaw = localStorage.getItem(localKey);
if (localRaw) {
const local = JSON.parse(localRaw) || [];
if (Array.isArray(local) && local.length > 0) {
await Promise.all(local.map(s => saveSession({
...s,
id: String(s.id), // legacy IDs were numbers; backend expects strings
repo: activeRepo,
})));
}
localStorage.removeItem(localKey);
}
} catch (e) {
console.warn("session migration failed:", e);
}
const remote = await fetchSessions(activeRepo);
if (!cancelled) setSessions(remote);
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRepo]);
// When the URL carries a session id, hydrate the chat panel from that
// session. Skips the fetch if the id matches what's already loaded so
// unrelated re-renders don't cause flicker.
useEffect(() => {
if (!activeRepo || !sessionIdFromUrl) return;
if (sessionIdFromUrl === sessionIdRef.current) return;
let cancelled = false;
(async () => {
const s = await fetchSession(sessionIdFromUrl);
if (cancelled || !s) return;
sessionIdRef.current = s.id;
setCurrentSessionId(s.id);
setMessages(s.messages || []);
setLastSources([]);
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "instant" }), 50);
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRepo, sessionIdFromUrl]);
// Auto-save current session after each complete streaming exchange
const prevStreaming = useRef(false);
useEffect(() => {
if (prevStreaming.current && !streaming && activeRepo && sessionIdRef.current) {
const next = upsertSession(activeRepo, sessionIdRef.current, messagesRef.current, agentMode);
if (next) setSessions(next);
}
prevStreaming.current = streaming;
}, [streaming, messages, activeRepo]);
// ββ Session actions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function handleLoadSession(session) {
if (streaming) return;
// Save whatever is currently open before switching
if (sessionIdRef.current && messagesRef.current.length > 0) {
upsertSession(activeRepo, sessionIdRef.current, messagesRef.current, agentMode);
}
// Navigate to the session URL β the sessionIdFromUrl effect picks up
// and hydrates the chat panel from the session record.
if (activeRepo) navigate(`/r/${activeRepo}/c/${session.id}`);
setShowReadme(false);
}
function handleDeleteSession(sessionId) {
setSessions(prev => prev.filter(s => s.id !== sessionId));
deleteSession(sessionId).catch(err => console.warn("session delete failed:", err));
// If we deleted the open session, clear the chat and drop the /c/:id
// segment from the URL so the user lands back on a fresh chat.
if (sessionIdRef.current === sessionId) {
sessionIdRef.current = null;
setCurrentSessionId(null);
setMessages([]);
setFocusFiles(null);
if (activeRepo) navigate(`/r/${activeRepo}/chat`);
}
}
function handleRenameSession(sessionId, newTitle) {
// Optimistic local update + persist via the same upsert path so a
// rename is identical to any other session edit on the wire.
setSessions(prev => {
const target = prev.find(s => s.id === sessionId);
if (target) {
saveSession({ ...target, title: newTitle, repo: activeRepo })
.catch(err => console.warn("session rename failed:", err));
}
return prev.map(s => s.id === sessionId ? { ...s, title: newTitle } : s);
});
}
function toggleSidebarCollapse() {
const next = !sidebarCollapsed;
setSidebarCollapsed(next);
localStorage.setItem('ghrc_sidebarCollapsed', String(next));
}
function handleDiagramThis(sources) {
// Extract unique filepaths from the message's source cards, then switch to
// the Diagram tab showing an architecture view with a focused-files banner.
const files = [...new Set((sources || []).map(s => s.filepath))];
setFocusFiles(files.length > 0 ? files : null);
setView("graph");
}
function handleStop() {
if (stopStream.current) { stopStream.current(); stopStream.current = null; }
setStreaming(false);
// Mark the in-progress message as done (no streaming cursor)
setMessages(prev => prev.map(m =>
m.streaming ? { ...m, streaming: false, phase: null, currentTool: null } : m
));
}
// βK / Ctrl+K β focus the input from anywhere in the app.
// Productivity Tool must_have: keyboard-shortcuts (ui-ux-pro-max-skill #16).
// navigator.platform is deprecated β prefer userAgentData (Chrome 90+) with fallback.
const isMac = (navigator.userAgentData?.platform ?? navigator.platform).toUpperCase().includes("MAC");
useEffect(() => {
function onGlobalKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
if (view === "graph") setView("chat");
// Small delay if we just switched views (textarea may not be mounted yet)
setTimeout(() => textareaRef.current?.focus(), 20);
}
// Escape β stop streaming (mirrors Claude/ChatGPT behaviour)
// Use streamingRef to avoid stale closure (this effect only reruns on view change)
if (e.key === "Escape" && streamingRef.current) {
if (stopStream.current) { stopStream.current(); stopStream.current = null; }
setStreaming(false);
setMessages(prev => prev.map(m =>
m.streaming ? { ...m, streaming: false, phase: null, currentTool: null } : m
));
}
}
window.addEventListener("keydown", onGlobalKey);
return () => window.removeEventListener("keydown", onGlobalKey);
}, [view]);
// Auto-grow textarea as user types
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [input]);
// Load repos on mount β also tracks backend health for the header status dot.
// Auto-selects the only repo if exactly one is indexed, so new users land
// directly in that repo's view rather than a bare landing screen.
const loadRepos = useCallback(async () => {
setReposLoading(true);
try {
const data = await fetchRepos();
const list = data.repos || [];
setRepos(list);
setBackendOk(true);
// Auto-select if only one repo is indexed and nothing is selected yet
if (list.length === 1 && !activeRepo) {
setActiveRepo(list[0].slug);
}
} catch {
setBackendOk(false);
} finally {
setReposLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { loadRepos(); }, [loadRepos]);
// Load MCP prompts once on mount for the "/" autocomplete
useEffect(() => {
fetchMcpStatus()
.then(info => setPrompts(info.prompts || []))
.catch(() => {});
}, []);
// Scroll to the TOP of the assistant message the moment it first appears.
// We track the last scrolled-to ID so this only fires once per response.
const scrolledToId = useRef(null);
useEffect(() => {
const streamingMsg = messages.find(m => m.role === "assistant" && m.streaming);
if (streamingMsg && streamingMsg.id !== scrolledToId.current) {
scrolledToId.current = streamingMsg.id;
setTimeout(() => {
latestAssistantRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 50);
}
}, [messages]);
// While streaming, keep scrolling to bottom only if user is already near bottom.
// After streaming ends, do a final smooth scroll to bottom.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (streaming) {
if (distFromBottom < 120) bottomRef.current?.scrollIntoView({ behavior: "instant" });
} else {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, streaming]);
// Accept optional retryQuestion so the rate-limit countdown can re-submit
// without reading stale `input` state from a closure.
function handleSubmit(e, retryQuestion = null) {
e?.preventDefault();
const question = retryQuestion || input.trim();
if (!question || streaming) return;
if (!retryQuestion) setInput(""); // only clear the box on a fresh submit
// Assign a session ID on the first message of a new conversation, then
// reflect it in the URL so the chat is bookmarkable / shareable from
// its very first message. UUIDs are URL-safe and globally unique so
// two users starting fresh chats don't collide.
if (!sessionIdRef.current) {
const id = (typeof crypto !== "undefined" && crypto.randomUUID)
? crypto.randomUUID()
: `s-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
sessionIdRef.current = id;
setCurrentSessionId(id);
if (activeRepo) navigate(`/r/${activeRepo}/c/${id}`, { replace: true });
}
// Build conversation history from completed exchanges (not the current one).
// Only include messages with content β skip failed/empty responses.
// Cap at 10 items (5 back-and-forth exchanges) to stay within LLM token limits.
const completedMsgs = messagesRef.current.filter(m => !m.streaming && m.content);
const history = completedMsgs
.slice(-10)
.map(m => ({ role: m.role, content: m.content }));
// Track query event
posthog.capture("query_submitted", { repo: activeRepo, mode: agentMode ? "agent" : "rag" });
// Add user message + placeholder assistant message.
// On auto-retry (retryQuestion set), skip the user message β it's already in the chat
// from the first attempt. Adding it again causes duplicate question bubbles.
const userMsg = { role: "user", content: question };
// Use a unique counter (not Date.now()) so auto-retry can never create a
// new message with the same ID as the old one β preventing a stale onSources
// callback from the old RAG stream polluting the new message's state.
const assistantId = ++msgIdCounter.current;
const assistantMsg = {
id: assistantId, role: "assistant",
// Store mode explicitly so Message.jsx never has to infer it from mutable state.
// phase, queryType, etc. can all be overwritten by async callbacks; mode cannot.
mode: agentMode ? "agent" : "rag",
content: "", sources: [], queryType: null, streaming: true,
phase: agentMode ? null : "searching",
sourceCount: null,
toolCalls: [], currentTool: null, iterations: null,
};
if (retryQuestion) {
setMessages((prev) => [...prev, assistantMsg]);
} else {
setMessages((prev) => [...prev, userMsg, assistantMsg]);
}
setStreaming(true);
// ββ Common callbacks ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const onToken = (token) =>
setMessages((prev) =>
prev.map((m) => m.id === assistantId ? { ...m, content: m.content + token } : m)
);
const onError = (err) => {
// Make errors actionable: distinguish network vs backend vs rate limit vs unknown.
const errStr = String(err);
let friendly = `Error: ${err}`;
let isRateLimit = false;
if (errStr.includes("fetch") || errStr.includes("network") || errStr.includes("Failed to fetch")) {
friendly = "Cannot reach the backend (localhost:8000). Is it running?\n\nTry: `uvicorn backend.main:app --reload`";
} else if (errStr.includes("502") || errStr.includes("503")) {
friendly = "Backend returned a server error (502/503). Try refreshing in a few seconds.";
} else if (
errStr.includes("429") ||
errStr.includes("rate-limited") ||
errStr.includes("rate limited") ||
errStr.includes("daily limit")
) {
isRateLimit = true;
friendly = "β³ Rate limited β retrying in 45s";
} else if (errStr.includes("timeout") || errStr.includes("Timeout")) {
friendly = "Request timed out. The query may be too complex β try a simpler question.";
}
setMessages((prev) =>
prev.map((m) => m.id === assistantId
? { ...m, content: friendly, streaming: false, rateLimited: isRateLimit, retryQuestion: isRateLimit ? question : null }
: m
)
);
setStreaming(false);
stopStream.current = null;
// Rate-limit auto-retry: count down 45 s, then re-submit the same question.
// Max 2 auto-retries β after that, show a permanent error so it doesn't loop forever.
// The user can also click "Retry now" to skip the wait (also counted against the limit).
if (isRateLimit) {
rateLimitRetries.current += 1;
const attempt = rateLimitRetries.current;
if (attempt > 2) {
// Give up β show a clear message instead of looping endlessly
setMessages(prev => prev.map(m =>
m.id === assistantId
? { ...m, content: "Rate limit hit too many times. Wait a minute and try again.", streaming: false, rateLimited: false }
: m
));
return;
}
let secsLeft = 45;
if (countdownTimer.current) clearInterval(countdownTimer.current);
countdownTimer.current = setInterval(() => {
secsLeft -= 1;
if (secsLeft <= 0) {
clearInterval(countdownTimer.current);
countdownTimer.current = null;
// Stop the old stream before retrying β prevents stale onSources/onToken
// callbacks from the previous attempt firing on the new message.
stopStream.current?.();
stopStream.current = null;
setMessages(prev => prev.filter(m => m.id !== assistantId));
handleSubmitRef.current?.(question);
} else {
setMessages(prev => prev.map(m =>
m.id === assistantId
? { ...m, content: `β³ Rate limited (attempt ${attempt}/2) β retrying in ${secsLeft}s` }
: m
));
}
}, 1000);
}
};
let stop;
if (agentMode) {
// ββ Agent mode: ReAct loop with live tool-call trace ββββββββββββββββββ
stop = streamAgentQuery({
question,
repo: activeRepo,
model_id: selectedModelId || undefined,
history,
onThought: (text) => {
// Append a thought entry to the trace β rendered as a reasoning bubble
// before the tool call that follows it.
setMessages((prev) =>
prev.map((m) => m.id === assistantId
? { ...m, toolCalls: [...m.toolCalls, { type: "thought", text }] }
: m
)
);
},
onToolCall: (tool, input) => {
// Show spinner with tool name while agent is calling
setMessages((prev) =>
prev.map((m) => m.id === assistantId
? { ...m, currentTool: tool }
: m
)
);
// Append to the tool call trace (output will be filled by onToolResult)
setMessages((prev) =>
prev.map((m) => m.id === assistantId
? { ...m, toolCalls: [...m.toolCalls, { tool, input, output: "" }] }
: m
)
);
},
onToolResult: (tool, output) => {
// Fill in the output of the last tool call in the trace
setMessages((prev) =>
prev.map((m) => {
if (m.id !== assistantId) return m;
const calls = [...m.toolCalls];
// Find the first (oldest) unfilled slot for this tool β results arrive
// in the same order as calls were emitted, so FIFO matching is correct.
// Scanning backwards was wrong: parallel same-name calls got swapped.
for (let i = 0; i < calls.length; i++) {
if (calls[i].tool === tool && !calls[i].output) {
calls[i] = { ...calls[i], output };
break;
}
}
return { ...m, toolCalls: calls, currentTool: "thinking" };
})
);
},
onToken,
onSources: (sources) => {
// Agent mode: store collected file references for the source cards panel.
// These arrive just before the "done" event, after all tool calls complete.
setMessages((prev) =>
prev.map((m) => m.id === assistantId
? { ...m, sources: sources || [] }
: m
)
);
},
onDone: (iterations, model) => {
rateLimitRetries.current = 0; // reset on success
setMessages((prev) =>
prev.map((m) => m.id === assistantId
? { ...m, streaming: false, currentTool: null, iterations, model }
: m
)
);
setStreaming(false);
stopStream.current = null;
},
onError,
});
} else {
// ββ Plain RAG mode: single retrieval β stream tokens ββββββββββββββββββ
stop = streamQuery({
question,
repo: activeRepo,
mode,
history,
onToken,
onSources: (sources, queryType, pipeline, model) => {
// Transition from "searching" β "generating" so the phase indicator updates.
// pipeline = {hyde, expanded, reranker} β shows which quality features fired.
setMessages((prev) =>
prev.map((m) => m.id === assistantId
? { ...m, sources, queryType, pipeline, model, phase: "generating", sourceCount: sources.length }
: m)
);
setLastSources(sources || []);
},
onGrade: (grade) => {
setMessages((prev) =>
prev.map((m) => m.id === assistantId ? { ...m, grade } : m)
);
},
onDone: () => {
rateLimitRetries.current = 0; // reset on success
setMessages((prev) =>
prev.map((m) => m.id === assistantId ? { ...m, streaming: false, phase: null } : m)
);
setStreaming(false);
stopStream.current = null;
},
onError,
});
}
stopStream.current = stop;
}
function handleInputChange(e) {
const val = e.target.value;
setInput(val);
// Show prompt menu when input is just "/" or "/partial"
if (val.startsWith("/") && !val.includes(" ")) {
setPromptFilter(val.slice(1).toLowerCase());
setPromptMenu(true);
} else {
setPromptMenu(false);
}
}
async function handleSelectPrompt(prompt) {
setPromptMenu(false);
// Build arguments: pass activeRepo if we have one
const args = activeRepo ? { repo: activeRepo } : {};
try {
const result = await fetchMcpPrompt(prompt.name, args);
setInput(result.text);
setTimeout(() => textareaRef.current?.focus(), 0);
} catch {
// Fallback: just fill with the prompt name as a question
setInput(`/${prompt.name}`);
}
}
function handleKeyDown(e) {
if (promptMenu && e.key === "Escape") {
setPromptMenu(false);
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function handleClear() {
if (stopStream.current) { stopStream.current(); stopStream.current = null; }
if (countdownTimer.current) { clearInterval(countdownTimer.current); countdownTimer.current = null; }
// Save the current session before starting a new one
if (sessionIdRef.current && messagesRef.current.length > 0) {
upsertSession(activeRepo, sessionIdRef.current, messagesRef.current, agentMode);
}
sessionIdRef.current = null;
setCurrentSessionId(null);
setMessages([]);
setFocusFiles(null);
setStreaming(false);
// Drop the /c/:sessionId segment from the URL so the next message
// gets its own fresh id (and shareable link).
if (activeRepo && sessionIdFromUrl) navigate(`/r/${activeRepo}/chat`);
}
// "/" triggers MCP prompt autocomplete β surface this in the placeholder so
// users discover it without reading docs.
const placeholder = activeRepo
? `Ask about ${activeRepo}β¦ (type / for AI prompts)`
: "Ask about any indexed repoβ¦";
// Landing mode = a fresh user with nowhere else to be. We dedicate the
// whole viewport to the hero in this state β sidebar collapses to an icon
// strip, the chat input hides, and the landing layout takes over.
const isLanding =
!showReadme &&
view === "chat" &&
messages.length === 0 &&
!activeRepo;
// Sidebar visibility follows the user's persisted preference in every state,
// landing included. Earlier we force-collapsed on landing to hand the whole
// viewport to the hero; it turned out the sidebar reads as context
// (indexed repos, sessions) rather than clutter, so we keep it visible.
const effectiveCollapsed = sidebarCollapsed;
// Landing β journey: tile click and URL input both start the same live
// ingestion experience that replaces the hero in-place. If the repo is
// already indexed, we skip the journey and select it directly.
//
// We build a full https:// URL here because the /ingest/stream endpoint
// expects one; accept any of the shorthand forms the hero input allows.
function toIngestUrl(input) {
const raw = (input || "").trim();
if (!raw) return null;
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
if (raw.startsWith("github.com/")) return `https://${raw}`;
if (raw.includes("/")) return `https://github.com/${raw}`;
return null;
}
function startJourney({ slug, url, accent }) {
// Don't stack journeys β if one's already running, ignore duplicate clicks.
if (activeJourney) return;
const ingestUrl = url || (slug ? `https://github.com/${slug}` : null);
if (!ingestUrl) return;
setActiveJourney({ url: ingestUrl, slug: slug || null, accent: accent || "#5B8FF9" });
}
function handleLandingPick(slug, accent) {
posthog.capture("landing_tile_clicked", { slug });
// If this repo is already indexed, skip the journey β the user has
// already seen the map form. Straight into the product via the iris
// reveal (cinematic iris from viewport centre).
const indexed = repos.find(r => r.slug === slug);
if (indexed) {
triggerReveal();
setActiveRepo(slug);
setShowReadme(false);
return;
}
startJourney({ slug, accent });
}
function handleLandingUrl(raw) {
posthog.capture("landing_url_submitted", { input: raw });
const url = toIngestUrl(raw);
if (!url) return;
triggerReveal();
startJourney({ url });
}
function triggerReveal() {
// Origin is always viewport-centre. We tried click-point origins first,
// but tiles sit in the bottom third of the viewport, so the iris read
// as "something happening near my cursor" instead of "the page is
// revealing itself." Centre-origin plays like a cinematic iris β the
// same gesture regardless of which tile was picked.
const el = document.documentElement;
el.style.setProperty("--reveal-x", "50vw");
el.style.setProperty("--reveal-y", "50vh");
el.classList.add("is-revealing");
window.setTimeout(() => el.classList.remove("is-revealing"), 1250);
}
// Journey completion: refresh the repo list so the sidebar picks up the
// newly indexed repo, then route the user straight into the Diagram view β
// that's the "understand this repo" destination. Chat is for questions;
// Diagram is the tour.
async function handleJourneyComplete(slug) {
if (!activeJourney) return;
const effectiveSlug = slug || activeJourney.slug;
setActiveJourney(null);
// Reload repos so the sidebar list updates (the new repo will appear).
await loadRepos();
if (effectiveSlug) {
setActiveRepo(effectiveSlug);
setShowReadme(false);
// Diagram view is the natural first stop for a brand-new repo β it
// shows the concept tour / structural overview. The user can still
// jump to chat any time. Journey β tour is the narrative promise.
setView("graph");
posthog.capture("landing_journey_completed", { repo: effectiveSlug });
}
}
function handleJourneyAbort() {
setActiveJourney(null);
}
function handleJourneyError(msg) {
posthog.capture("landing_journey_error", { message: msg });
// Keep the overlay visible so the user can read the error and retry
// via the "Back" button; the component shows its own error copy.
}
return (
<div className={`layout${effectiveCollapsed ? " layout-collapsed" : ""}${isLanding ? " layout-landing" : ""}${diagramFullscreen ? " layout-fullscreen" : ""}`}>
<CustomCursor />
{/* Sidebar overlay for mobile β closes sidebar when clicking outside */}
{sidebarOpen && (
<div className="sidebar-overlay" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
)}
<Sidebar
repos={repos}
reposLoading={reposLoading}
activeRepo={activeRepo}
onSelectRepo={(repo) => { setActiveRepo(repo); setShowReadme(false); posthog.capture("repo_selected", { repo }); }}
onReposChange={loadRepos}
mode={mode}
onModeChange={setMode}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
sessions={sessions}
currentSessionId={currentSessionId}
onLoadSession={handleLoadSession}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
collapsed={effectiveCollapsed}
onToggleCollapse={toggleSidebarCollapse}
onGenerateReadme={(repo) => { setActiveRepo(repo); setShowReadme(true); posthog.capture("readme_opened", { repo }); }}
isLanding={isLanding}
/>
{/* .main is the universal canvas for every view. We apply the same
ambient primitives (cursor-glow + constellation parallax) that make
the landing feel premium, so every surface β chat empty state,
diagram, explore, story, readme β shares one ambient language.
Individual views can still add tighter card-level glows on top. */}
<div
className="main has-cursor-glow constellation-bg"
onMouseMove={(e) => {
// Shared --mx/--my channel: one handler on .main feeds both the
// cursor-glow pseudo and the constellation parallax. Custom
// properties inherit, so any descendant that reads var(--mx)/
// var(--my) will see the same values without its own handler.
const r = e.currentTarget.getBoundingClientRect();
const mx = ((e.clientX - r.left) / r.width) * 100;
const my = ((e.clientY - r.top) / r.height) * 100;
e.currentTarget.style.setProperty("--mx", `${mx}%`);
e.currentTarget.style.setProperty("--my", `${my}%`);
}}
>
{/* Header */}
{/* 3-column grid: left (repo badge) | center (toggle) | right (actions)
Equal 1fr flanks guarantee the center column is always truly centred,
regardless of asymmetric content on either side β the Linear/Vercel pattern. */}
<div className="chat-header">
{/* LEFT β hamburger (mobile) + repo context */}
<div className="header-left">
<button
className="mobile-menu-btn"
onClick={() => setSidebarOpen(true)}
aria-label="Open navigation"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 010 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 010 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H1.75z"/>
</svg>
</button>
{backendOk !== null && (
<span
className="backend-dot"
title={backendOk ? "Backend connected" : "Backend unreachable"}
style={{ background: backendOk ? "var(--green)" : "var(--red)" }}
/>
)}
{activeRepo && (() => {
const [owner, name] = activeRepo.split("/");
return (
<span className="repo-badge">
<span style={{ opacity: 0.55, fontWeight: 400 }}>{owner}/</span>
<span style={{ fontWeight: 600 }}>{name}</span>
</span>
);
})()}
{/* Agent-mode indicator β persistent badge when agent mode is active.
Pulses when a query is streaming (the "thinking" state). Makes
mode immediately legible from any view, not just the sidebar pill. */}
{agentMode && activeRepo && (
<span
className="agent-badge"
data-thinking={streaming || undefined}
title={streaming ? "Agent is investigating your question" : "Agent mode is active"}
>
<span className="agent-badge-mark" aria-hidden="true">β¦</span>
<span className="agent-badge-label">
{streaming ? "Agent thinkingβ¦" : "Agent"}
</span>
</span>
)}
</div>
{/* CENTER β view toggle, only when a specific repo is selected */}
<div className="header-center">
{activeRepo && (
<div className="view-toggle">
<button
className={`view-btn ${view === "chat" && !showReadme ? "active" : ""}`}
onClick={() => { setView("chat"); setShowReadme(false); }}
>Chat</button>
<button
className={`view-btn ${view === "graph" && !showReadme ? "active" : ""}`}
onClick={() => { setView("graph"); setShowReadme(false); posthog.capture("diagram_view_opened", { repo: activeRepo }); }}
>Diagram <span style={{ fontSize: 8, verticalAlign: "middle", color: "var(--accent-soft)", marginLeft: 2 }}>β</span></button>
</div>
)}
</div>
{/* RIGHT β contextual actions */}
<div className="header-actions">
{view === "chat" && messages.length > 0 && (
<button className="clear-btn" onClick={handleClear}>New Chat</button>
)}
</div>
</div>
{/* ββ README view ββ */}
{showReadme && activeRepo && (
<ReadmeView
repo={activeRepo}
contextualAt={repos.find(r => r.slug === activeRepo)?.contextual_at ?? null}
onClose={() => setShowReadme(false)}
/>
)}
{/* ββ Diagram view ββ */}
{/* Keyed to activeRepo so switching repos (or from chatβdiagram) replays
.view-switch-in. Matches the tab transition inside DiagramView and
the mode transition inside ExploreView. */}
{!showReadme && view === "graph" && activeRepo && (
<div key={`diag-${activeRepo}`} className="view-switch-in app-view-host">
<DiagramView
repo={activeRepo}
focusFiles={focusFiles}
onAskAbout={(question) => {
setView("chat");
setFocusFiles(null);
setInput(question);
setTimeout(() => textareaRef.current?.focus(), 50);
}}
onFullscreenChange={setDiagramFullscreen}
/>
</div>
)}
{/* ββ Chat view ββ */}
{!showReadme && view === "chat" && (
<>
{messages.length === 0 ? (
isLanding ? (
// Full-bleed landing β LandingHero owns its own layout, or
// LandingIngestion takes over when a journey is in flight.
// We key on activeJourney so the entrance animation replays
// on mount β the feeling of "the map starting to form."
<div className="landing-root view-switch-in">
{activeJourney ? (
<LandingIngestion
key={`journey-${activeJourney.url}`}
repoUrl={activeJourney.url}
repoSlug={activeJourney.slug}
accent={activeJourney.accent}
onComplete={handleJourneyComplete}
onError={handleJourneyError}
onAbort={handleJourneyAbort}
/>
) : (
<LandingHero
onPickRepo={handleLandingPick}
onPasteUrl={handleLandingUrl}
/>
)}
</div>
) : (
<div
className="empty-state has-cursor-glow"
// Shared --mx/--my channel: one mousemove feeds both the
// glow pseudo and the constellation parallax. Percentages
// are used so the transforms are resolution-independent.
onMouseMove={(e) => {
const r = e.currentTarget.getBoundingClientRect();
const mx = ((e.clientX - r.left) / r.width) * 100;
const my = ((e.clientY - r.top) / r.height) * 100;
e.currentTarget.style.setProperty("--mx", `${mx}%`);
e.currentTarget.style.setProperty("--my", `${my}%`);
}}
style={{ "--glow-size": "640px", "--glow-intensity": "6%" }}
>
{/* Repo selected β show mode-aware suggestions + feature discovery cards */}
<div className="suggest-state">
<h2>How does {activeRepo.split("/")[1]} work?</h2>
{agentMode ? (
<>
<div className="mode-hint" style={{ marginBottom: 12 }}>
<strong>Agent mode</strong> β search β observe β reason β search again. Watch the ReAct loop work in real time.
</div>
<div className="suggestions">
{[
{ icon: "architecture", title: "Map the architecture", body: `Walk ${activeRepo.split("/")[1]} from entry point to output`, q: `How is ${activeRepo.split("/")[1]} structured? Trace the main execution path from the entry point all the way to the output.` },
{ icon: "functions", title: "Key functions", body: "Most important functions and how they connect", q: `What are the most important functions in ${activeRepo.split("/")[1]} and how do they call each other?` },
{ icon: "diagram", title: "Generate a diagram", body: "Visual map of the main components", q: `Generate a diagram of the main components in ${activeRepo.split("/")[1]} and how they relate to each other.` },
{ icon: "shield", title: "Error handling", body: "How edge cases are managed across the codebase", q: `How does ${activeRepo.split("/")[1]} handle errors and edge cases?` },
{ icon: "flow", title: "Data flow", body: "How data moves from input to final result", q: `How does data flow through ${activeRepo.split("/")[1]} from input to final result?` },
].map(({ icon, title, body, q }, i) => {
return (
<button key={title} className="suggestion-btn"
style={{ animationDelay: `${150 + i * 120}ms` }}
onClick={() => { setInput(q); textareaRef.current?.focus(); }}>
<span className="suggestion-icon">{ICONS[icon]}</span>
<span className="suggestion-content">
<span className="suggestion-title">{title}</span>
<span className="suggestion-body">{body}</span>
</span>
<svg className="suggestion-arrow" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</button>
);
})}
</div>
<button className="graph-hint-btn" onClick={() => setView("graph")}>
Explore Diagrams for {activeRepo.split("/")[1]} β
</button>
</>
) : (
<>
<div className="suggestions">
{[
{ icon: "architecture", title: "Overall architecture", body: `How is ${activeRepo.split("/")[1]} structured?`, q: `How is ${activeRepo.split("/")[1]} structured overall? What are the main components and how do they fit together?` },
{ icon: "entry", title: "Entry points", body: "Main entry points and how the code flows", q: `What are the main entry points of ${activeRepo.split("/")[1]} and how does execution flow through them?` },
{ icon: "classes", title: "Key classes", body: "What each major class does", q: `What are the key classes in ${activeRepo.split("/")[1]} and what is each one responsible for?` },
{ icon: "flow", title: "Data processing", body: "How data is transformed through the system", q: `How is data transformed and processed as it flows through ${activeRepo.split("/")[1]}?` },
{ icon: "package", title: "Dependencies", body: "External libraries and how they're used", q: `What external libraries does ${activeRepo.split("/")[1]} depend on and how does it use them?` },
].map(({ icon, title, body, q }, i) => {
return (
<button key={title} className="suggestion-btn"
style={{ animationDelay: `${150 + i * 120}ms` }}
onClick={() => { setInput(q); textareaRef.current?.focus(); }}>
<span className="suggestion-icon">{ICONS[icon]}</span>
<span className="suggestion-content">
<span className="suggestion-title">{title}</span>
<span className="suggestion-body">{body}</span>
</span>
<svg className="suggestion-arrow" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
</button>
);
})}
</div>
{/* Secondary action row β below suggestions so it doesn't compete */}
<div className="suggest-footer">
<button className="suggest-footer-btn" onClick={() => setAgentMode(true)}>
β¦ Agent mode
</button>
<span className="suggest-footer-sep">Β·</span>
<button className="suggest-footer-btn" onClick={() => setView("graph")}>
β« Diagrams
</button>
</div>
</>
)}
</div>
</div>
)
) : (
<div
className="messages"
ref={scrollRef}
role="log"
aria-live="polite"
aria-label="Chat messages"
>
{messages.map((msg, i) => (
<Message
key={msg.id ?? i}
msg={msg}
showRepo={!activeRepo}
onDiagramThis={activeRepo ? handleDiagramThis : null}
ref={msg.role === "assistant" && msg.streaming ? latestAssistantRef : null}
onRetry={msg.rateLimited && msg.retryQuestion ? (q) => {
// User clicked "Retry now" β cancel countdown and re-submit immediately
if (countdownTimer.current) { clearInterval(countdownTimer.current); countdownTimer.current = null; }
setMessages(prev => prev.filter(m => m.id !== msg.id));
handleSubmit(null, q);
} : null}
/>
))}
<div ref={bottomRef} />
</div>
)}
{/* Input β hidden on landing (there's no repo to chat about yet) */}
{!isLanding && (
<div className="input-bar">
{/* Prompt autocomplete dropdown β shown when input starts with "/" */}
{promptMenu && prompts.length > 0 && (() => {
const filtered = prompts.filter(p =>
p.name.toLowerCase().includes(promptFilter)
);
return filtered.length > 0 ? (
<div className="prompt-menu">
<div className="prompt-menu-label">MCP Prompts</div>
{filtered.map(p => (
<button
key={p.name}
className="prompt-menu-item"
onMouseDown={(e) => { e.preventDefault(); handleSelectPrompt(p); }}
>
<span className="prompt-menu-name">/{p.name}</span>
<span className="prompt-menu-desc">{p.description?.slice(0, 60)}β¦</span>
</button>
))}
</div>
) : null;
})()}
<div className="input-row">
<textarea
ref={textareaRef}
rows={1}
placeholder={agentMode ? "Ask a complex questionβ¦" : placeholder}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={() => setTimeout(() => setPromptMenu(false), 150)}
disabled={streaming}
/>
<button
className={`btn${streaming ? " btn-stop" : ""}`}
onClick={streaming ? handleStop : handleSubmit}
disabled={!streaming && !input.trim()}
aria-label={streaming ? "Stop generating" : agentMode ? "Run Agent" : "Send"}
title={streaming ? "Stop (Esc)" : undefined}
>
{streaming
? <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><rect x="1.5" y="1.5" width="9" height="9" rx="2"/></svg>
: <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8h10M8 4l4 4-4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"/></svg>
}
</button>
{/* βK hint β inside input-row so it positions relative to the textarea, not the whole bar */}
{!streaming && !input && (
<div className="input-hint" aria-hidden="true">{isMac ? "βK" : "Ctrl+K"}</div>
)}
</div>
{/* Agent mode footer: badge + model selector */}
{agentMode && (
<div className="input-footer-row">
<div className="input-mode-badge" title="Agent mode β runs the ReAct loop (Reason + Act): searches the codebase, reads the result, decides if it needs more context, then searches again. The same pattern production agents use.">β¦ Agent</div>
{agentModels.length > 0 && (() => {
const active = agentModels.find(m => m.id === selectedModelId) || agentModels.find(m => m.available) || agentModels[0];
return (
<div className="model-selector" ref={modelMenuRef}>
<button
className="model-selector-btn"
onClick={() => setModelMenuOpen(o => !o)}
title={active?.note}
>
<span className="model-selector-name">{active?.name ?? "Auto"}</span>
{active && <span className={`model-speed-badge model-speed-${active.speed}`}>{active.speed_label}</span>}
{/* chevron */}
<svg className={`model-chevron${modelMenuOpen ? " open" : ""}`} width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{modelMenuOpen && (
<div className="model-menu">
{agentModels.map(m => (
<button
key={m.id}
className={`model-menu-item${m.id === selectedModelId ? " active" : ""}${!m.available ? " unavailable" : ""}`}
onClick={() => { setSelectedModelId(m.id); setModelMenuOpen(false); }}
disabled={!m.available}
title={!m.available ? `Requires ${m.provider} API key` : undefined}
>
<div className="model-menu-row">
<span className="model-menu-name">{m.name}</span>
<span className={`model-speed-badge model-speed-${m.speed}`}>{m.speed_label}</span>
{m.id === selectedModelId && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{marginLeft:"auto",flexShrink:0}}>
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
<div className="model-menu-note">{m.note}</div>
{!m.available && <div className="model-menu-unavail">API key not configured</div>}
</button>
))}
</div>
)}
</div>
);
})()}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
);
}
|