personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chat: replace universal bar chrome with compact bar + talent-view modal (lode 3b)

Delete the 680-line inline/panel JS block in app.html and the
conversation_panel.html template. In their place:

- convey/templates/chat_bar.html: compact always-visible bar with a
single-line sol-message slot, talent icon tray (max 8 + overflow),
and composer row. Subscribes to the new "chat" tract (from 3a).
- convey/templates/app.html: shared talent-view modal (role=dialog,
aria-modal, ESC-closes, focus-return). Static mode fetches
/api/chat/talent-log/<use_id>; running mode also short-polls and
watches the chat tract for terminal events.
- window.openConversation(text?) repurposed: focus bar input,
pre-fill text. Panel semantics gone.

Gating decision: add app_bar: bool = True to the App dataclass so
/app/chat (lode 3c) can opt out. Body .has-app-bar class and the
bar include both gate on app_registry.apps[app].app_bar. Workspace
bottom-space reservation moved under body.has-app-bar .workspace
so a bar-less app reclaims the space.

Deleted DOM/CSS/JS: conversationBackdrop, conversationMessages,
chatBarResponsePanel, chatBarThinking, chatBarResponse, chatBarDismiss,
conversation-separator, app-bar--focused/dismissing/glance, expand
button, panel focus trap, pagehide saves, the two solstone:*State
localStorage keys, and the /api/chat/result recovery path. One-time
localStorage cleanup remains.

Guard extended: tests/test_no_legacy_chat_imports.py now text-scans
.html and .js files (excluding itself and fixtures) for the deleted
DOM literals, so they can't creep back in.

net: -1084 lines old / +735 lines new across 7 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+737 -1070
+8
apps/__init__.py
··· 28 28 "label": "Custom Label", # Display label (default: title-cased app name) 29 29 "facets": {}, # Facet options: {"disabled": true} to hide facet bar 30 30 "date_nav": true, # Show date navigation bar (default: false) 31 + "app_bar": false, # Hide the universal chat bar on this app (default: true) 31 32 "allow_future_dates": true # Allow future dates in month picker (default: false) 32 33 } 33 34 ··· 72 73 73 74 # Date navigation (renders date nav below facet bar) 74 75 date_nav: bool = False 76 + 77 + # Hide the universal chat bar on this app 78 + app_bar: bool = True 75 79 76 80 # Allow clicking future dates in month picker (for todos) 77 81 allow_future_dates: bool = False ··· 167 171 # Date navigation 168 172 date_nav = metadata.get("date_nav", False) 169 173 174 + # Universal app bar 175 + app_bar = metadata.get("app_bar", True) 176 + 170 177 # Allow future dates in month picker 171 178 allow_future_dates = metadata.get("allow_future_dates", False) 172 179 ··· 234 241 background_template=background_template, 235 242 facets_config=facets_config, 236 243 date_nav=date_nav, 244 + app_bar=app_bar, 237 245 allow_future_dates=allow_future_dates, 238 246 ) 239 247
+2 -2
apps/home/workspace.html
··· 1399 1399 if (el.closest('[data-routine-click]') || el.hasAttribute('data-routine-click')) { 1400 1400 fetch('/app/home/api/routines/seen', {method: 'POST'}); 1401 1401 } 1402 - window.openConversation({ prompt: el.dataset.conversation }); 1402 + window.openConversation(el.dataset.conversation); 1403 1403 } 1404 1404 }); 1405 1405 dashboard.addEventListener('keydown', function(e) { ··· 1423 1423 if (el.closest('[data-routine-click]') || el.hasAttribute('data-routine-click')) { 1424 1424 fetch('/app/home/api/routines/seen', {method: 'POST'}); 1425 1425 } 1426 - window.openConversation({ prompt: el.dataset.conversation }); 1426 + window.openConversation(el.dataset.conversation); 1427 1427 } 1428 1428 }); 1429 1429 }
+199 -434
convey/static/app.css
··· 107 107 margin-top: var(--facet-bar-height); 108 108 padding-top: 8px; 109 109 margin-left: var(--menu-bar-width-minimal); 110 + } 111 + 112 + body.has-app-bar .workspace { 110 113 margin-bottom: calc(var(--app-bar-height) + 80px); 111 114 } 112 115 ··· 117 120 118 121 /* Reserve space when error log is visible */ 119 122 body.has-error-log .workspace { 123 + margin-bottom: 30vh; 124 + } 125 + 126 + body.has-app-bar.has-error-log .workspace { 120 127 margin-bottom: calc(var(--app-bar-height) + 80px + 30vh); 121 128 } 122 129 ··· 1385 1392 filter: grayscale(0.4); 1386 1393 } 1387 1394 1388 - /* App Bar (bottom) */ 1395 + /* chat bar + talent-view modal */ 1389 1396 .app-bar { 1390 1397 position: fixed; 1391 1398 bottom: 16px; ··· 1397 1404 margin-right: auto; 1398 1405 z-index: var(--z-bars); 1399 1406 min-height: var(--app-bar-height); 1400 - padding: 0; 1407 + padding: 10px 14px; 1401 1408 display: flex; 1402 - flex-direction: column; 1409 + align-items: center; 1410 + gap: 12px; 1411 + flex-wrap: wrap; 1403 1412 border-radius: 12px; 1404 1413 border: 1px solid var(--facet-border, #e5e0db); 1405 1414 box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05), 0 2px 12px rgba(0, 0, 0, 0.08); ··· 1408 1417 .app-bar::before { 1409 1418 content: ''; 1410 1419 position: absolute; 1411 - top: 0; 1412 - left: 0; 1413 - right: 0; 1414 - bottom: 0; 1420 + inset: 0; 1415 1421 background: white; 1416 1422 pointer-events: none; 1417 1423 z-index: -2; ··· 1421 1427 .app-bar::after { 1422 1428 content: ''; 1423 1429 position: absolute; 1424 - top: 0; 1425 - left: 0; 1426 - right: 0; 1427 - bottom: 0; 1430 + inset: 0; 1428 1431 background: var(--facet-bg, #b06a1a1a); 1429 1432 transition: background-color 0.3s ease; 1430 1433 pointer-events: none; ··· 1432 1435 border-radius: 12px; 1433 1436 } 1434 1437 1435 - .app-bar:not(.app-bar--focused):hover { 1436 - cursor: pointer; 1437 - } 1438 - 1439 - .app-bar:not(.app-bar--focused) .chat-bar-input-row { 1440 - cursor: default; 1441 - } 1442 - 1443 - .app-bar:not(.app-bar--focused) .chat-bar-input { 1444 - border-color: transparent; 1438 + .chat-bar-status { 1439 + flex: 1 1 auto; 1440 + min-width: 0; 1441 + overflow: hidden; 1445 1442 } 1446 1443 1447 - /* Response panel (above input row) */ 1448 - .chat-bar-response-panel { 1449 - max-height: 0; 1444 + .chat-bar-status #chatBarStatusText, 1445 + .chat-bar-status > span { 1446 + display: block; 1450 1447 overflow: hidden; 1451 - padding: 0 20px; 1452 - position: relative; 1453 - z-index: 1; 1454 - transition: max-height 0.2s ease; 1455 - } 1456 - 1457 - .app-bar--glance .chat-bar-response-panel { 1458 - max-height: 200px; 1459 - } 1460 - 1461 - .app-bar--glance .chat-bar-thinking { 1462 - margin-top: 12px; 1463 - margin-bottom: 8px; 1464 - opacity: 1; 1448 + text-overflow: ellipsis; 1449 + white-space: nowrap; 1450 + color: #888; 1451 + font-size: 13px; 1452 + line-height: 1.4; 1465 1453 } 1466 1454 1467 - .app-bar--glance .chat-bar-response { 1468 - margin-top: 12px; 1469 - margin-bottom: 8px; 1470 - opacity: 1; 1471 - } 1472 - 1473 - .chat-bar-separator { 1474 - height: 1px; 1475 - background: var(--facet-border, #e5e0db); 1476 - margin: 12px -20px 0; 1477 - } 1478 - 1479 - .chat-bar-dismiss { 1480 - position: absolute; 1481 - top: 12px; 1482 - right: 16px; 1483 - min-width: 44px; 1484 - min-height: 44px; 1485 - border: none; 1486 - background: transparent; 1487 - color: #999; 1488 - font-size: 16px; 1489 - font-weight: 300; 1490 - cursor: pointer; 1491 - border-radius: 4px; 1455 + .chat-bar-talents { 1492 1456 display: flex; 1493 1457 align-items: center; 1494 - justify-content: center; 1495 - z-index: 2; 1496 - opacity: 0; 1497 - pointer-events: none; 1498 - visibility: hidden; 1499 - transition: opacity 0.15s ease, visibility 0.15s ease; 1500 - } 1501 - 1502 - .chat-bar-dismiss.visible { 1503 - opacity: 1; 1504 - pointer-events: auto; 1505 - visibility: visible; 1458 + gap: 4px; 1459 + max-width: calc(8 * 24px); 1460 + overflow: hidden; 1506 1461 } 1507 1462 1508 - .chat-bar-dismiss:hover { 1509 - background: rgba(0, 0, 0, 0.06); 1510 - color: #666; 1511 - } 1512 - 1513 - .chat-bar-dismiss:active { 1514 - background: rgba(0, 0, 0, 0.10); 1515 - } 1516 - 1517 - /* Input row */ 1518 - .chat-bar-input-row { 1463 + .chat-bar-form { 1519 1464 display: flex; 1520 - flex-direction: row; 1521 1465 align-items: center; 1522 - gap: 8px; 1523 - padding: 0 20px; 1524 - min-height: var(--app-bar-height); 1466 + gap: 6px; 1525 1467 } 1526 1468 1527 - /* Chat Bar Elements */ 1528 1469 .chat-bar-input { 1529 - flex: 1; 1530 1470 resize: none; 1531 - border: 1px solid var(--facet-border, #e5e0db); 1532 - border-radius: 20px; 1533 - padding: 0.6em 1.25em; 1534 - font-family: inherit; 1535 - font-size: 15px; 1536 - line-height: 1.5; 1471 + overflow: hidden; 1537 1472 max-height: 120px; 1538 - min-height: 38px; 1539 - background: transparent; 1540 - position: relative; 1541 - z-index: 1; 1542 - transition: border-color 0.2s ease; 1543 - } 1544 - 1545 - .chat-bar-input:hover { 1546 - border-color: #bbb; 1473 + min-width: 240px; 1474 + padding: 6px 8px; 1475 + border: 1px solid var(--facet-border, #e5e0db); 1476 + border-radius: 6px; 1477 + background: rgba(255, 255, 255, 0.92); 1478 + color: inherit; 1479 + font: inherit; 1480 + line-height: 1.4; 1547 1481 } 1548 1482 1549 1483 .chat-bar-input:focus { 1550 - outline: none; 1551 - border-color: var(--facet-color, #b06a1a); 1484 + outline: 2px solid var(--facet-color, #b06a1a); 1485 + outline-offset: 1px; 1552 1486 } 1553 1487 1554 - .chat-bar-input::placeholder { 1555 - color: #aaa; 1556 - transition: opacity 0.2s ease; 1488 + .chat-bar-input:disabled { 1489 + opacity: 0.5; 1490 + cursor: not-allowed; 1557 1491 } 1558 1492 1559 - .chat-bar-input:focus::placeholder { 1560 - opacity: 0.5; 1493 + .chat-bar-send { 1494 + padding: 6px 12px; 1495 + border: 1px solid var(--facet-border, #e5e0db); 1496 + border-radius: 6px; 1497 + background: rgba(255, 255, 255, 0.88); 1498 + color: inherit; 1499 + font: inherit; 1500 + cursor: pointer; 1561 1501 } 1562 1502 1563 - .chat-bar-input:disabled { 1503 + .chat-bar-send:disabled { 1564 1504 opacity: 0.5; 1565 1505 cursor: not-allowed; 1566 1506 } 1567 1507 1568 - .chat-bar-send { 1569 - width: 44px; 1570 - height: 44px; 1508 + .chat-bar-talent { 1509 + width: 20px; 1510 + height: 20px; 1511 + border: 1px solid var(--facet-border, #e5e0db); 1571 1512 border-radius: 50%; 1572 - border: none; 1573 - background: var(--facet-color, #b06a1a); 1574 - color: white; 1575 - cursor: pointer; 1576 - display: flex; 1513 + background: transparent; 1514 + color: #888; 1515 + padding: 0; 1516 + display: inline-flex; 1577 1517 align-items: center; 1578 1518 justify-content: center; 1579 - position: relative; 1580 - z-index: 1; 1519 + cursor: pointer; 1581 1520 flex-shrink: 0; 1582 - opacity: 0; 1583 - pointer-events: none; 1584 - visibility: hidden; 1585 - transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease; 1586 - } 1587 - 1588 - .chat-bar-send.visible { 1589 - opacity: 1; 1590 - pointer-events: auto; 1591 - visibility: visible; 1592 - } 1593 - 1594 - .chat-bar-send.visible:hover { 1595 - opacity: 0.85; 1596 1521 } 1597 1522 1598 - .chat-bar-send.visible:active { 1599 - transform: scale(0.92); 1600 - opacity: 0.75; 1601 - } 1602 - 1603 - @media (max-width: 768px) { 1604 - .chat-bar-send { 1605 - width: 48px; 1606 - height: 48px; 1607 - } 1608 - } 1609 - 1610 - .chat-bar-thinking { 1611 - display: flex; 1612 - align-items: center; 1613 - gap: 0.5em; 1614 - color: #999; 1615 - font-size: 0.9em; 1616 - position: relative; 1617 - z-index: 1; 1618 - overflow: hidden; 1619 - text-overflow: ellipsis; 1620 - white-space: nowrap; 1621 - min-width: 0; 1622 - opacity: 0; 1623 - transition: opacity 0.15s ease 0.05s; 1624 - } 1625 - 1626 - .chat-bar-thinking > [aria-hidden="true"] { 1627 - overflow: hidden; 1628 - text-overflow: ellipsis; 1629 - min-width: 0; 1630 - transition: opacity 0.15s ease; 1631 - } 1632 - 1633 - .chat-bar-thinking-dot { 1523 + .chat-bar-talent-dot { 1634 1524 width: 8px; 1635 1525 height: 8px; 1636 - background: var(--facet-color, #b06a1a); 1637 1526 border-radius: 50%; 1638 - animation: chat-bar-pulse 1s ease-in-out infinite; 1639 - } 1640 - 1641 - @keyframes chat-bar-pulse { 1642 - 0%, 100% { opacity: 0.3; } 1643 - 50% { opacity: 1; } 1644 - } 1645 - 1646 - .thinking-elapsed { 1647 - opacity: 0; 1648 - font-variant-numeric: tabular-nums; 1649 - transition: opacity 0.3s ease; 1650 - } 1651 - 1652 - .chat-bar-response { 1653 - font-size: 14px; 1654 - line-height: 1.5; 1655 - color: #666; 1656 - max-height: 5em; 1657 - overflow: hidden; 1658 - padding-right: 28px; 1659 - position: relative; 1660 - z-index: 1; 1661 - -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%); 1662 - mask-image: linear-gradient(to bottom, black 60%, transparent 100%); 1663 - opacity: 0; 1664 - transition: opacity 0.2s ease 0.1s; 1665 - } 1666 - 1667 - .chat-bar-response--error { 1668 - color: #d97706; 1669 - } 1670 - 1671 - /* ======================================== 1672 - * Conversation Panel 1673 - * Expandable focus-mode conversation surface. 1674 - * The chat bar expands upward to 80% viewport. 1675 - * Backdrop blur excludes aruco corner-tags via z-index layering: 1676 - * backdrop at z-bars+49 < corner-tags at z-tags (400). 1677 - * ======================================== */ 1678 - 1679 - /* Backdrop — blurs convey content when panel is open */ 1680 - .conversation-backdrop { 1681 - position: fixed; 1682 - inset: 0; 1683 - z-index: calc(var(--z-bars) + 49); 1684 - backdrop-filter: blur(8px); 1685 - -webkit-backdrop-filter: blur(8px); 1686 - background: rgba(0, 0, 0, 0.12); 1687 - opacity: 0; 1688 - pointer-events: none; 1689 - transition: opacity 0.3s ease; 1527 + background: currentColor; 1690 1528 } 1691 1529 1692 - .conversation-backdrop.visible { 1693 - opacity: 1; 1694 - pointer-events: auto; 1695 - cursor: pointer; 1530 + .chat-bar-talent[data-status="active"] { 1531 + color: var(--facet-color, #b06a1a); 1696 1532 } 1697 1533 1698 - /* Panel expanded state */ 1699 - .app-bar.app-bar--focused { 1700 - height: 80vh; /* fallback for browsers without dvh */ 1701 - height: 80dvh; 1702 - bottom: 0; 1703 - border-radius: 12px 12px 0 0; 1704 - z-index: calc(var(--z-bars) + 50); 1705 - animation: panel-expand 0.3s cubic-bezier(0.4, 0, 0.2, 1); 1534 + .chat-bar-talent[data-status="active"] .chat-bar-talent-dot { 1535 + animation: chat-bar-talent-pulse 1.2s ease-in-out infinite; 1706 1536 } 1707 1537 1708 - /* Panel closing animation */ 1709 - .app-bar.app-bar--dismissing { 1710 - z-index: calc(var(--z-bars) + 50); 1711 - animation: panel-collapse 0.25s ease forwards; 1712 - } 1713 - 1714 - @keyframes panel-expand { 1715 - from { 1716 - height: var(--app-bar-height); 1717 - bottom: 16px; 1718 - border-radius: 12px; 1719 - } 1538 + .chat-bar-talent--overflow { 1539 + width: auto; 1540 + min-width: 28px; 1541 + height: 20px; 1542 + border-radius: 10px; 1543 + padding: 0 8px; 1544 + font-size: 11px; 1545 + font-weight: 600; 1546 + line-height: 1; 1720 1547 } 1721 1548 1722 - @keyframes panel-collapse { 1723 - from { 1724 - height: 80vh; 1725 - height: 80dvh; 1726 - bottom: 0; 1727 - border-radius: 12px 12px 0 0; 1728 - } 1729 - to { 1730 - height: var(--app-bar-height); 1731 - bottom: 16px; 1732 - border-radius: 12px; 1733 - } 1549 + .chat-bar-talent[data-status="errored"] { 1550 + color: #b42318; 1734 1551 } 1735 1552 1736 - @media (max-width: 768px) { 1737 - .app-bar.app-bar--focused { 1738 - left: 8px; 1739 - right: 8px; 1740 - max-width: none; 1741 - width: auto; 1742 - } 1553 + @keyframes chat-bar-talent-pulse { 1554 + 0%, 100% { opacity: 1; } 1555 + 50% { opacity: 0.3; } 1743 1556 } 1744 1557 1745 - /* Conversation messages area */ 1746 - .conversation-messages { 1558 + .talent-view-modal[hidden] { 1747 1559 display: none; 1748 - flex-direction: column; 1749 - gap: 12px; 1750 - overflow-y: auto; 1751 - padding: 16px; 1752 - flex: 1; 1753 - min-height: 0; 1754 - background: #fffcf9; 1755 - scrollbar-width: thin; 1756 - scrollbar-color: rgba(0, 0, 0, 0.15) transparent; 1757 1560 } 1758 1561 1759 - .conversation-messages::-webkit-scrollbar { 1760 - width: 6px; 1562 + .talent-view-modal { 1563 + position: fixed; 1564 + inset: 0; 1565 + z-index: var(--z-modals); 1566 + display: flex; 1567 + align-items: center; 1568 + justify-content: center; 1761 1569 } 1762 1570 1763 - .conversation-messages::-webkit-scrollbar-thumb { 1764 - background: rgba(0, 0, 0, 0.15); 1765 - border-radius: 3px; 1571 + .talent-view-backdrop { 1572 + position: absolute; 1573 + inset: 0; 1574 + background: rgba(0, 0, 0, 0.6); 1766 1575 } 1767 1576 1768 - .app-bar.app-bar--focused .conversation-messages { 1577 + .talent-view-panel { 1578 + position: relative; 1579 + width: min(760px, calc(100vw - 48px)); 1580 + max-height: calc(100vh - 80px); 1769 1581 display: flex; 1582 + flex-direction: column; 1583 + border: 1px solid var(--facet-border, #e5e0db); 1584 + border-radius: 10px; 1585 + background: white; 1586 + overflow: hidden; 1770 1587 } 1771 1588 1772 - /* Hide inline response panel in panel mode */ 1773 - .app-bar.app-bar--focused .chat-bar-response-panel { 1774 - display: none; 1589 + .talent-view-header { 1590 + display: flex; 1591 + align-items: center; 1592 + gap: 8px; 1593 + padding: 12px 16px; 1594 + border-bottom: 1px solid var(--facet-border, #e5e0db); 1775 1595 } 1776 1596 1777 - /* Separator between messages and input (panel mode only) */ 1778 - .conversation-separator { 1779 - display: none; 1597 + .talent-view-title { 1598 + flex: 1 1 auto; 1599 + min-width: 0; 1600 + margin: 0; 1601 + font-size: 15px; 1602 + font-weight: 600; 1603 + overflow: hidden; 1604 + text-overflow: ellipsis; 1605 + white-space: nowrap; 1780 1606 } 1781 1607 1782 - .app-bar.app-bar--focused .conversation-separator { 1783 - display: block; 1784 - height: 1px; 1785 - background: var(--facet-border, #e5e0db); 1786 - flex-shrink: 0; 1608 + .talent-view-status { 1609 + padding: 2px 8px; 1610 + border-radius: 999px; 1611 + font-size: 11px; 1612 + letter-spacing: 0.04em; 1613 + text-transform: uppercase; 1787 1614 } 1788 1615 1789 - /* Message bubbles */ 1790 - .conversation-message { 1791 - max-width: 85%; 1792 - padding: 10px 16px; 1793 - border-radius: 16px; 1794 - font-size: 14px; 1795 - line-height: 1.5; 1796 - word-wrap: break-word; 1797 - white-space: pre-wrap; 1616 + .talent-view-status[data-status="running"] { 1617 + background: rgba(176, 106, 26, 0.16); 1618 + color: var(--facet-color, #b06a1a); 1798 1619 } 1799 1620 1800 - .conversation-message.user { 1801 - align-self: flex-end; 1802 - background: var(--facet-color, #b06a1a); 1803 - color: white; 1804 - border-bottom-right-radius: 4px; 1805 - font-weight: 500; 1621 + .talent-view-status[data-status="completed"] { 1622 + background: rgba(15, 23, 42, 0.08); 1623 + color: #888; 1806 1624 } 1807 1625 1808 - .conversation-message.agent { 1809 - align-self: flex-start; 1810 - background: #f5f0eb; 1811 - color: #1f2937; 1812 - border-bottom-left-radius: 4px; 1813 - white-space: normal; 1814 - word-break: break-word; 1815 - overflow-wrap: break-word; 1626 + .talent-view-status[data-status="errored"] { 1627 + background: rgba(180, 35, 24, 0.16); 1628 + color: #b42318; 1816 1629 } 1817 1630 1818 - .conversation-message.agent p { 1819 - margin: 0 0 0.6em 0; 1631 + .talent-view-close { 1632 + background: transparent; 1633 + border: 0; 1634 + color: inherit; 1635 + font-size: 20px; 1636 + cursor: pointer; 1637 + padding: 0 4px; 1820 1638 } 1821 1639 1822 - .conversation-message.agent p:last-child { 1823 - margin-bottom: 0; 1640 + .talent-view-timeline { 1641 + flex: 1 1 auto; 1642 + padding: 12px 16px; 1643 + overflow: auto; 1824 1644 } 1825 1645 1826 - .conversation-message.agent ul, 1827 - .conversation-message.agent ol { 1828 - margin: 0.25em 0 0.5em 0; 1829 - padding-left: 1.5em; 1646 + .talent-view-empty { 1647 + margin: 0; 1648 + color: #6b7280; 1830 1649 } 1831 1650 1832 - .conversation-message.agent li { 1833 - margin-bottom: 0.2em; 1651 + .talent-view-event { 1652 + margin-bottom: 12px; 1653 + font-size: 13px; 1834 1654 } 1835 1655 1836 - .conversation-message.agent code { 1837 - background: rgba(0, 0, 0, 0.06); 1838 - padding: 0.15em 0.35em; 1839 - border-radius: 4px; 1840 - font-size: 0.9em; 1841 - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; 1656 + .talent-view-event-header { 1657 + display: flex; 1658 + align-items: baseline; 1659 + justify-content: space-between; 1660 + gap: 12px; 1661 + margin-bottom: 8px; 1842 1662 } 1843 1663 1844 - .conversation-message.agent pre { 1845 - background: rgba(0, 0, 0, 0.06); 1846 - padding: 0.6em 0.8em; 1847 - border-radius: 6px; 1848 - overflow-x: auto; 1849 - margin: 0.4em 0; 1664 + .talent-view-event-label { 1665 + font-weight: 600; 1850 1666 } 1851 1667 1852 - .conversation-message.agent pre code { 1853 - background: none; 1854 - padding: 0; 1855 - border-radius: 0; 1856 - font-size: 0.875em; 1857 - white-space: pre; 1668 + .talent-view-event-time { 1669 + color: #6b7280; 1670 + font-size: 12px; 1671 + flex-shrink: 0; 1858 1672 } 1859 1673 1860 - .conversation-message.agent blockquote { 1861 - border-left: 3px solid rgba(0, 0, 0, 0.15); 1862 - margin: 0.4em 0; 1863 - padding: 0.2em 0 0.2em 0.8em; 1864 - color: #6b7280; 1674 + .talent-view-event--thinking, 1675 + .talent-view-thinking { 1676 + color: #888; 1677 + font-style: italic; 1865 1678 } 1866 1679 1867 - .conversation-message.agent h1, 1868 - .conversation-message.agent h2, 1869 - .conversation-message.agent h3, 1870 - .conversation-message.agent h4, 1871 - .conversation-message.agent h5, 1872 - .conversation-message.agent h6 { 1873 - margin: 0.6em 0 0.3em 0; 1874 - font-weight: 600; 1875 - line-height: 1.3; 1680 + .talent-view-info { 1681 + line-height: 1.5; 1876 1682 } 1877 1683 1878 - .conversation-message.agent h1 { font-size: 1.2em; } 1879 - .conversation-message.agent h2 { font-size: 1.1em; } 1880 - .conversation-message.agent h3 { font-size: 1.08em; } 1881 - 1882 - .conversation-message.agent a { 1883 - color: #b06a1a; 1884 - text-decoration: underline; 1885 - text-decoration-color: rgba(176, 106, 26, 0.3); 1684 + .talent-view-pre { 1685 + margin: 0; 1686 + padding: 10px 12px; 1687 + border: 1px solid rgba(0, 0, 0, 0.06); 1688 + border-radius: 8px; 1689 + background: rgba(15, 23, 42, 0.03); 1690 + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; 1691 + white-space: pre-wrap; 1692 + word-break: break-word; 1693 + overflow: auto; 1886 1694 } 1887 1695 1888 - .conversation-message.agent a:hover { 1889 - text-decoration-color: rgba(176, 106, 26, 0.7); 1696 + .talent-view-event--tool summary, 1697 + .talent-view-event details > summary { 1698 + cursor: pointer; 1699 + font-weight: 600; 1890 1700 } 1891 1701 1892 - .conversation-message.agent hr { 1893 - border: none; 1894 - border-top: 1px solid rgba(0, 0, 0, 0.1); 1895 - margin: 0.5em 0; 1702 + .talent-view-event--error { 1703 + color: #b42318; 1896 1704 } 1897 1705 1898 - .conversation-message.agent.thinking { 1899 - display: flex; 1900 - align-items: center; 1901 - gap: 0.5em; 1902 - color: #999; 1903 - font-size: 0.9em; 1904 - background: #f5f0eb; 1905 - } 1706 + @media (max-width: 768px) { 1707 + .app-bar { 1708 + padding: 10px; 1709 + gap: 10px; 1710 + } 1906 1711 1907 - .conversation-message.error { 1908 - align-self: flex-start; 1909 - background: rgba(217, 119, 6, 0.06); 1910 - color: #1f2937; 1911 - border-left: 4px solid #d97706; 1912 - border-bottom-left-radius: 4px; 1913 - font-weight: 500; 1914 - } 1712 + .chat-bar-form { 1713 + min-width: 0; 1714 + width: 100%; 1715 + } 1915 1716 1916 - .conversation-message.error::before { 1917 - content: '\26A0\FE0F '; 1918 - color: #d97706; 1717 + .talent-view-modal { 1718 + padding: 12px; 1719 + } 1919 1720 } 1920 1721 1921 1722 /* Error log display */ ··· 2406 2207 cursor: pointer; 2407 2208 } 2408 2209 2409 - /* Visually-hidden expand/collapse button - focusable but invisible */ 2410 - .chat-bar-expand { 2411 - position: absolute; 2412 - width: 1px; 2413 - height: 1px; 2414 - padding: 0; 2415 - margin: -1px; 2416 - overflow: hidden; 2417 - clip: rect(0, 0, 0, 0); 2418 - white-space: nowrap; 2419 - border: 0; 2420 - background: none; 2421 - font: inherit; 2422 - color: inherit; 2423 - } 2424 - 2425 - .chat-bar-expand:focus-visible { 2426 - position: static; 2427 - width: auto; 2428 - height: auto; 2429 - margin: 0; 2430 - overflow: visible; 2431 - clip: auto; 2432 - white-space: normal; 2433 - font-size: 14px; 2434 - padding: 4px 8px; 2435 - border-radius: 4px; 2436 - background: rgba(0, 0, 0, 0.05); 2437 - } 2438 - 2439 2210 /* ======================================== 2440 2211 * Reduced Motion 2441 2212 * Respects prefers-reduced-motion user preference ··· 2453 2224 animation-iteration-count: 1 !important; 2454 2225 } 2455 2226 2456 - .chat-bar-thinking-dot { 2227 + .chat-bar-talent[data-status="active"] .chat-bar-talent-dot { 2457 2228 animation: none; 2458 - opacity: 1; 2459 2229 } 2460 2230 2461 2231 .status-indicator--connecting { ··· 2494 2264 transform: none; 2495 2265 } 2496 2266 2497 - .chat-bar-send.visible:active { 2498 - transform: none; 2499 - } 2500 - 2501 2267 .mp-day:active:not(.mp-other):not(.mp-empty) { 2502 2268 transform: none; 2503 2269 } ··· 2522 2288 #hamburger:focus-visible, 2523 2289 .facet-bar .status-icon:focus-visible, 2524 2290 .chat-bar-send:focus-visible, 2525 - .chat-bar-dismiss:focus-visible, 2526 2291 .date-nav-arrow:focus-visible, 2527 2292 .facet-create-submit:focus-visible, 2528 2293 .facet-create-cancel:focus-visible,
-1
convey/static/websocket.js
··· 214 214 215 215 // Built-in tract: navigate browser to a path and/or switch facet 216 216 listeners['navigate'] = [function(msg) { 217 - if (window._closeConversationPanel) window._closeConversationPanel(); 218 217 if (msg.facet && !msg.path) { 219 218 window.selectFacet && window.selectFacet(msg.facet); 220 219 } else if (msg.path) {
+468 -601
convey/templates/app.html
··· 34 34 {% endif %} 35 35 </head> 36 36 {% set body_classes = [] %} 37 - {% set _ = body_classes.append('has-app-bar') %} 37 + {% if app_registry.apps[app].app_bar %}{% set _ = body_classes.append('has-app-bar') %}{% endif %} 38 38 {% if app_registry.apps[app].date_nav_enabled() and day %}{% set _ = body_classes.append('has-date-nav') %}{% endif %} 39 39 <body{% if body_classes %} class="{{ body_classes|join(' ') }}"{% endif %}> 40 40 <script> ··· 72 72 <!-- Notification Center --> 73 73 <div class="notification-center" id="notification-center" role="log" aria-live="polite" aria-label="notifications"></div> 74 74 75 - <!-- Conversation Panel (universal chrome) --> 76 - {% include "conversation_panel.html" %} 75 + <div id="talentViewModal" class="talent-view-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="talentViewTitle" hidden> 76 + <div class="talent-view-backdrop" data-action="close"></div> 77 + <div class="talent-view-panel"> 78 + <header class="talent-view-header"> 79 + <h2 id="talentViewTitle" class="talent-view-title"></h2> 80 + <span id="talentViewStatus" class="talent-view-status" data-status=""></span> 81 + <button type="button" class="talent-view-close" data-action="close" aria-label="close">&times;</button> 82 + </header> 83 + <div id="talentViewTimeline" class="talent-view-timeline" role="region" aria-label="talent timeline"></div> 84 + </div> 85 + </div> 86 + 87 + <!-- Universal Chat Bar --> 88 + {% if app_registry.apps[app].app_bar %} 89 + {% include "chat_bar.html" %} 90 + {% endif %} 77 91 78 92 <!-- Main Content --> 79 93 <main id="main-content" class="workspace"> ··· 87 101 88 102 <script> 89 103 (function() { 90 - // --- Conversation Panel --- 91 - // The chat bar expands into a focus-mode conversation panel. 92 - // Multi-turn messages displayed in scrollable area, localStorage persistence, 93 - // inline-vs-panel decision, backdrop blur with aruco marker exclusion. 94 - 104 + const APP_NAME = {{ app|tojson|safe }}; 95 105 const appBar = document.getElementById('appBar'); 96 - function sanitizeAgentHtml(html) { 97 - const doc = new DOMParser().parseFromString(html, 'text/html'); 98 - const allowedTags = new Set([ 99 - 'p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'a', 100 - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'br', 'hr' 101 - ]); 102 - const allowedAttributes = { 103 - a: new Set(['href']) 104 - }; 105 - 106 - Array.from(doc.body.querySelectorAll('*')).forEach(function(el) { 107 - const tagName = el.tagName.toLowerCase(); 108 - if (!allowedTags.has(tagName)) { 109 - el.replaceWith(doc.createTextNode(el.textContent || '')); 110 - return; 111 - } 112 - 113 - Array.from(el.attributes).forEach(function(attr) { 114 - if (!allowedAttributes[tagName] || !allowedAttributes[tagName].has(attr.name)) { 115 - el.removeAttribute(attr.name); 116 - } 117 - }); 118 - 119 - if (tagName === 'a') { 120 - const href = el.getAttribute('href'); 121 - if (!href || !/^(https?:\/\/|mailto:)/.test(href)) { 122 - el.removeAttribute('href'); 123 - } 124 - el.setAttribute('target', '_blank'); 125 - el.setAttribute('rel', 'noopener noreferrer'); 126 - } 127 - }); 106 + const statusWrap = document.getElementById('chatBarStatus'); 107 + const statusText = document.getElementById('chatBarStatusText'); 108 + const talentsTray = document.getElementById('chatBarTalents'); 109 + const form = document.getElementById('chatBarForm'); 110 + const input = document.getElementById('chatBarInput'); 111 + const sendBtn = document.getElementById('chatBarSend'); 112 + const modal = document.getElementById('talentViewModal'); 113 + const modalPanel = modal ? modal.querySelector('.talent-view-panel') : null; 114 + const modalTitle = document.getElementById('talentViewTitle'); 115 + const modalStatus = document.getElementById('talentViewStatus'); 116 + const modalTimeline = document.getElementById('talentViewTimeline'); 117 + const talentState = new Map(); 118 + const LEGACY_KEYS = ['solstone:' + 'conversationState', 'solstone:' + 'chatBarState']; 119 + let trayPage = 0; 120 + let pendingSend = false; 121 + let modalUseId = null; 122 + let modalPollTimer = null; 123 + let modalChatCleanup = null; 124 + let modalTrigger = null; 128 125 129 - return doc.body.innerHTML; 126 + function escapeHtml(value) { 127 + return String(value || '') 128 + .replace(/&/g, '&amp;') 129 + .replace(/</g, '&lt;') 130 + .replace(/>/g, '&gt;') 131 + .replace(/"/g, '&quot;') 132 + .replace(/'/g, '&#39;'); 130 133 } 131 134 132 - function renderAgentContent(content) { 133 - if (typeof marked !== 'undefined') { 134 - return sanitizeAgentHtml(marked.parse(content, { breaks: true, gfm: true })); 135 + function formatEventTime(ts) { 136 + if (typeof ts !== 'number') return ''; 137 + try { 138 + return new Date(ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); 139 + } catch (_err) { 140 + return ''; 135 141 } 136 - return sanitizeAgentHtml('<p>' + content.replace(/\n/g, '<br>') + '</p>'); 137 142 } 138 143 139 - const backdrop = document.getElementById('conversationBackdrop'); 140 - const msgArea = document.getElementById('conversationMessages'); 141 - const responsePanel = document.getElementById('chatBarResponsePanel'); 142 - const thinking = document.getElementById('chatBarThinking'); 143 - const inlineResp = document.getElementById('chatBarResponse'); 144 - const dismissBtn = document.getElementById('chatBarDismiss'); 145 - const input = document.getElementById('chatMessageInput'); 146 - const form = document.getElementById('chatInputForm'); 147 - const sendBtn = document.getElementById('chatBarSend'); 148 - const expandBtn = document.getElementById('chatExpandBtn'); 149 - const STORE_KEY = 'solstone:conversationState'; 144 + function describeValue(value) { 145 + if (value == null) return ''; 146 + if (typeof value === 'string') return value; 147 + try { 148 + return JSON.stringify(value, null, 2); 149 + } catch (_err) { 150 + return String(value); 151 + } 152 + } 150 153 151 - if (!input || !form) return; 152 - 153 - // Clean up old storage key 154 - try { localStorage.removeItem('solstone:chatBarState'); } catch {} 154 + function clipTitle(text) { 155 + var value = String(text || '').trim(); 156 + if (!value) return ''; 157 + return value.length > 120 ? value.slice(0, 117) + '...' : value; 158 + } 155 159 156 - let panelOpen = false; 157 - let messages = []; 158 - let savedScrollTop = 0; 159 - let pendingMessage = null; 160 - let previouslyFocusedElement = null; 161 - let panelFocusTrapHandler = null; 160 + function resizeComposer() { 161 + if (!input) return; 162 + input.style.height = 'auto'; 163 + input.style.height = Math.min(120, input.scrollHeight) + 'px'; 164 + } 162 165 163 - // --- State persistence --- 164 - function save() { 165 - try { 166 - localStorage.setItem(STORE_KEY, JSON.stringify({ 167 - messages: messages, 168 - scrollTop: msgArea ? msgArea.scrollTop : 0, 169 - inputText: input.value, 170 - inputSelection: [input.selectionStart, input.selectionEnd], 171 - pendingMessage: pendingMessage 172 - })); 173 - } catch {} 166 + function setStatus(text, title) { 167 + if (!statusText || !statusWrap) return; 168 + var label = String(text || '').trim(); 169 + var tooltip = String(title || label).trim(); 170 + statusText.textContent = label; 171 + statusText.title = tooltip; 172 + statusWrap.title = tooltip; 174 173 } 175 174 176 - function load() { 177 - try { 178 - const s = JSON.parse(localStorage.getItem(STORE_KEY)); 179 - if (s && Array.isArray(s.messages)) { 180 - messages = s.messages; 181 - savedScrollTop = s.scrollTop || 0; 182 - return s; 183 - } 184 - } catch {} 185 - return null; 175 + function setPendingState(active) { 176 + pendingSend = !!active; 177 + if (input) input.disabled = pendingSend; 178 + if (sendBtn) sendBtn.disabled = pendingSend; 186 179 } 187 180 188 - function clearConversation() { 189 - messages = []; 190 - savedScrollTop = 0; 191 - pendingMessage = null; 192 - try { localStorage.removeItem(STORE_KEY); } catch {} 193 - if (msgArea) msgArea.innerHTML = ''; 181 + function getTalentEntries() { 182 + return Array.from(talentState.values()).sort(function(a, b) { 183 + var aActive = a.status === 'active' ? 1 : 0; 184 + var bActive = b.status === 'active' ? 1 : 0; 185 + if (aActive !== bActive) return bActive - aActive; 186 + return (b.updatedAt || 0) - (a.updatedAt || 0); 187 + }); 194 188 } 195 189 196 - // --- Render messages in panel --- 197 - function renderMessages() { 198 - if (!msgArea) return; 199 - msgArea.innerHTML = ''; 200 - var lastIdx = messages.length - 1; 201 - for (var i = 0; i <= lastIdx; i++) { 202 - var m = messages[i]; 203 - const d = document.createElement('div'); 204 - d.className = 'conversation-message ' + m.role; 205 - if (m.role === 'error' && i === lastIdx) d.setAttribute('role', 'alert'); 206 - if (m.role === 'agent') { 207 - d.innerHTML = renderAgentContent(m.content); 208 - } else { 209 - d.textContent = m.content; 210 - } 211 - msgArea.appendChild(d); 212 - } 190 + function upsertTalent(entry) { 191 + if (!entry || !entry.useId) return; 192 + var current = talentState.get(entry.useId) || {}; 193 + talentState.set(entry.useId, { 194 + useId: entry.useId, 195 + name: entry.name || current.name || '', 196 + task: entry.task || current.task || '', 197 + status: entry.status || current.status || 'active', 198 + updatedAt: entry.updatedAt || Date.now() 199 + }); 200 + renderTalentTray(); 213 201 } 214 202 215 - // --- Panel open/close --- 216 - function openPanel() { 217 - if (panelOpen) return; 218 - previouslyFocusedElement = document.activeElement; 219 - panelOpen = true; 220 - if (expandBtn) expandBtn.setAttribute('aria-label', 'collapse conversation'); 221 - if (expandBtn) expandBtn.setAttribute('aria-expanded', 'true'); 222 - appBar.classList.remove('app-bar--dismissing'); 223 - appBar.classList.add('app-bar--focused'); 224 - appBar.setAttribute('role', 'dialog'); 225 - appBar.setAttribute('aria-modal', 'true'); 226 - backdrop.classList.add('visible'); 227 - renderMessages(); 228 - document.querySelector('.facet-bar')?.setAttribute('aria-hidden', 'true'); 229 - document.querySelector('.menu-bar')?.setAttribute('aria-hidden', 'true'); 230 - document.getElementById('main-content')?.setAttribute('aria-hidden', 'true'); 231 - responsePanel.style.display = 'none'; 232 - input.focus(); 233 - panelFocusTrapHandler = function(e) { 234 - if (e.key !== 'Tab') return; 235 - var focusable = Array.from( 236 - appBar.querySelectorAll('button, textarea, [tabindex="0"]') 237 - ).filter(function(el) { return el.offsetParent !== null && el.tabIndex >= 0 && !el.disabled; }); 238 - if (focusable.length === 0) return; 239 - var first = focusable[0]; 240 - var last = focusable[focusable.length - 1]; 241 - if (e.shiftKey && document.activeElement === first) { 242 - e.preventDefault(); 243 - last.focus(); 244 - } else if (!e.shiftKey && document.activeElement === last) { 245 - e.preventDefault(); 246 - first.focus(); 247 - } 248 - }; 249 - document.addEventListener('keydown', panelFocusTrapHandler); 250 - requestAnimationFrame(() => { 251 - if (!msgArea) return; 252 - if (savedScrollTop > 0) { 253 - msgArea.scrollTop = savedScrollTop; 254 - savedScrollTop = 0; 255 - } else { 256 - msgArea.scrollTop = msgArea.scrollHeight; 257 - } 203 + function renderTalentChip(entry) { 204 + var button = document.createElement('button'); 205 + var label = clipTitle(entry.task || entry.name || entry.useId); 206 + button.type = 'button'; 207 + button.className = 'chat-bar-talent'; 208 + button.dataset.useId = entry.useId; 209 + button.dataset.status = entry.status; 210 + button.title = label; 211 + button.setAttribute('aria-label', 'talent: ' + (label || entry.useId) + '; status: ' + entry.status); 212 + button.innerHTML = '<span class="chat-bar-talent-dot" aria-hidden="true"></span>'; 213 + button.addEventListener('click', function() { 214 + window.openTalentView(entry.useId, { live: entry.status === 'active' }); 258 215 }); 216 + return button; 259 217 } 260 218 261 - function closePanel() { 262 - if (!panelOpen) return; 263 - panelOpen = false; 264 - if (expandBtn) expandBtn.setAttribute('aria-label', 'expand conversation'); 265 - if (expandBtn) expandBtn.setAttribute('aria-expanded', 'false'); 266 - save(); 267 - appBar.classList.add('app-bar--dismissing'); 268 - backdrop.classList.remove('visible'); 269 - document.querySelector('.facet-bar')?.removeAttribute('aria-hidden'); 270 - document.querySelector('.menu-bar')?.removeAttribute('aria-hidden'); 271 - document.getElementById('main-content')?.removeAttribute('aria-hidden'); 272 - if (panelFocusTrapHandler) { 273 - document.removeEventListener('keydown', panelFocusTrapHandler); 274 - panelFocusTrapHandler = null; 275 - } 276 - if (previouslyFocusedElement && previouslyFocusedElement.isConnected) { 277 - previouslyFocusedElement.focus(); 278 - } else { 279 - input.focus(); 219 + function renderTalentTray() { 220 + if (!talentsTray) return; 221 + talentsTray.innerHTML = ''; 222 + var entries = getTalentEntries(); 223 + if (!entries.length) { 224 + talentsTray.hidden = true; 225 + trayPage = 0; 226 + return; 280 227 } 281 - previouslyFocusedElement = null; 228 + 229 + talentsTray.hidden = false; 230 + var pageSize = entries.length > 8 ? 7 : 8; 231 + var pageCount = entries.length > 8 ? Math.ceil(entries.length / pageSize) : 1; 232 + if (trayPage >= pageCount) trayPage = 0; 233 + 234 + var start = trayPage * pageSize; 235 + var visible = entries.slice(start, start + pageSize); 236 + visible.forEach(function(entry) { 237 + talentsTray.appendChild(renderTalentChip(entry)); 238 + }); 282 239 283 - function onDone() { 284 - appBar.classList.remove('app-bar--focused', 'app-bar--dismissing'); 285 - appBar.setAttribute('role', 'region'); 286 - appBar.removeAttribute('aria-modal'); 287 - responsePanel.style.display = ''; 240 + if (entries.length > 8) { 241 + var hiddenCount = entries.length - visible.length; 242 + var overflow = document.createElement('button'); 243 + overflow.type = 'button'; 244 + overflow.className = 'chat-bar-talent chat-bar-talent--overflow'; 245 + overflow.textContent = '+' + hiddenCount; 246 + overflow.setAttribute('aria-label', 'show more talents'); 247 + overflow.addEventListener('click', function() { 248 + trayPage = (trayPage + 1) % pageCount; 249 + renderTalentTray(); 250 + }); 251 + talentsTray.appendChild(overflow); 288 252 } 289 - appBar.addEventListener('animationend', onDone, { once: true }); 290 - setTimeout(onDone, 260); // fallback 291 253 } 292 254 293 - // Expose for websocket navigate handler 294 - window._closeConversationPanel = closePanel; 295 - 296 - // --- Public conversation API --- 297 - // Generic API for any app to open the conversation panel with a pre-filled prompt. 298 - window.openConversation = function(options) { 299 - options = options || {}; 300 - if (options.prompt) { 301 - input.value = options.prompt; 302 - input.dispatchEvent(new Event('input')); 255 + function stopTalentLiveUpdates() { 256 + if (modalPollTimer) { 257 + window.clearTimeout(modalPollTimer); 258 + modalPollTimer = null; 303 259 } 304 - if (options.autoSend) { 305 - form.requestSubmit(); 306 - } else { 307 - input.focus(); 308 - input.setSelectionRange(input.value.length, input.value.length); 309 - if (options.prompt && options.prompt.length > 40) { 310 - openPanel(); 311 - } 260 + if (modalChatCleanup) { 261 + modalChatCleanup(); 262 + modalChatCleanup = null; 312 263 } 313 - }; 314 - 315 - // Save state before navigation 316 - window.addEventListener('pagehide', save); 317 - 318 - // --- Backdrop click = dismiss --- 319 - backdrop.addEventListener('click', closePanel); 264 + } 320 265 321 - // --- Click bar to expand (not on textarea/buttons) --- 322 - appBar.addEventListener('click', function(e) { 323 - if (panelOpen) return; 324 - if (e.target.closest('textarea, button')) return; 325 - openPanel(); 326 - }); 327 - 328 - // --- Explicit expand/collapse button --- 329 - if (expandBtn) { 330 - expandBtn.addEventListener('click', (e) => { 331 - e.stopPropagation(); 332 - if (panelOpen) { 333 - closePanel(); 334 - } else { 335 - openPanel(); 336 - } 266 + function getModalFocusables() { 267 + if (!modalPanel) return []; 268 + return Array.from( 269 + modalPanel.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') 270 + ).filter(function(element) { 271 + return !element.disabled && !element.hidden && element.offsetParent !== null; 337 272 }); 338 273 } 339 274 340 - // --- Escape to collapse panel --- 341 - document.addEventListener('keydown', function(e) { 342 - if (e.key === 'Escape' && panelOpen) { 343 - closePanel(); 275 + function handleModalKeys(event) { 276 + if (!modal || modal.hidden) return; 277 + if (event.key === 'Escape') { 278 + event.preventDefault(); 279 + hideTalentView(); 280 + return; 344 281 } 345 - }); 282 + if (event.key !== 'Tab') return; 346 283 347 - // --- Textarea auto-resize + debounced save --- 348 - let inputSaveTimer; 349 - input.addEventListener('input', function() { 350 - input.style.height = 'auto'; 351 - input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 352 - sendBtn.classList.toggle('visible', !!input.value.trim()); 353 - clearTimeout(inputSaveTimer); 354 - inputSaveTimer = setTimeout(save, 300); 355 - }); 284 + var focusables = getModalFocusables(); 285 + if (!focusables.length) return; 286 + var first = focusables[0]; 287 + var last = focusables[focusables.length - 1]; 356 288 357 - // --- Focus on load --- 358 - setTimeout(function() { 359 - if (document.activeElement === document.body) input.focus(); 360 - }, 100); 289 + if (event.shiftKey && document.activeElement === first) { 290 + event.preventDefault(); 291 + last.focus(); 292 + } else if (!event.shiftKey && document.activeElement === last) { 293 + event.preventDefault(); 294 + first.focus(); 295 + } 296 + } 361 297 362 - // --- Restore from localStorage --- 363 - var saved = load(); 364 - if (saved) { 365 - // Restore input text 366 - if (saved.inputText) { 367 - input.value = saved.inputText; 368 - input.style.height = 'auto'; 369 - input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 370 - sendBtn.classList.toggle('visible', !!input.value.trim()); 298 + function hideTalentView() { 299 + if (!modal) return; 300 + stopTalentLiveUpdates(); 301 + modal.hidden = true; 302 + modal.setAttribute('aria-hidden', 'true'); 303 + modalUseId = null; 304 + document.removeEventListener('keydown', handleModalKeys); 305 + if (modalTrigger && typeof modalTrigger.focus === 'function') { 306 + modalTrigger.focus(); 371 307 } 308 + modalTrigger = null; 309 + } 372 310 373 - // Restore cursor position 374 - if (saved.inputSelection) { 375 - try { input.setSelectionRange(saved.inputSelection[0], saved.inputSelection[1]); } catch {} 376 - } 311 + function showTalentView() { 312 + if (!modal) return; 313 + modal.hidden = false; 314 + modal.setAttribute('aria-hidden', 'false'); 315 + document.removeEventListener('keydown', handleModalKeys); 316 + document.addEventListener('keydown', handleModalKeys); 317 + var closeButton = modal.querySelector('.talent-view-close'); 318 + if (closeButton) closeButton.focus(); 319 + } 377 320 378 - // Restore glance state (last agent response inline) 379 - if (messages.length > 0) { 380 - var last = null; 381 - for (var i = messages.length - 1; i >= 0; i--) { 382 - if (messages[i].role === 'agent') { last = messages[i]; break; } 383 - } 384 - if (last && inlineResp) { 385 - inlineResp.innerHTML = renderAgentContent(last.content); 386 - inlineResp.style.display = ''; 387 - dismissBtn.classList.add('visible'); 388 - appBar.classList.add('app-bar--glance'); 389 - } 390 - } 321 + function createTimelineCard(label, ts, className) { 322 + var card = document.createElement('article'); 323 + card.className = 'talent-view-event' + (className ? ' ' + className : ''); 391 324 392 - // Auto-recover pending request if < 5 minutes old 393 - if (saved.pendingMessage && saved.pendingMessage.agentId && saved.pendingMessage.sentAt) { 394 - var age = Date.now() - saved.pendingMessage.sentAt; 395 - if (age < 300000) { 396 - pendingMessage = saved.pendingMessage; 397 - thinking.style.display = ''; 398 - inlineResp.style.display = 'none'; 399 - dismissBtn.classList.remove('visible'); 400 - appBar.classList.add('app-bar--glance'); 325 + var header = document.createElement('div'); 326 + header.className = 'talent-view-event-header'; 401 327 402 - (async function() { 403 - var agentId = pendingMessage.agentId; 404 - var recoveryCleanup = null; 405 - var recoveryWatchdog = null; 406 - var resolved = false; 328 + var title = document.createElement('span'); 329 + title.className = 'talent-view-event-label'; 330 + title.textContent = label; 407 331 408 - function recoverCleanup() { 409 - if (recoveryCleanup) { recoveryCleanup(); recoveryCleanup = null; } 410 - if (recoveryWatchdog) { clearTimeout(recoveryWatchdog); recoveryWatchdog = null; } 411 - thinking.style.display = 'none'; 412 - input.disabled = false; 413 - input.focus(); 414 - } 332 + var time = document.createElement('span'); 333 + time.className = 'talent-view-event-time'; 334 + time.textContent = formatEventTime(ts); 415 335 416 - function recoverDeliver(resp, display, errMsg) { 417 - if (resolved) return; 418 - resolved = true; 419 - var content = errMsg || resp; 420 - if (content) { 421 - messages.push({ role: errMsg ? 'error' : 'agent', content: content, ts: Date.now() }); 422 - } 423 - pendingMessage = null; 336 + header.appendChild(title); 337 + header.appendChild(time); 338 + card.appendChild(header); 339 + return card; 340 + } 424 341 425 - // Multi-turn conversations always use panel — after the first exchange, 426 - // the conversation has enough context that panel view is more appropriate. 427 - var userCount = messages.filter(function(m) { return m.role === 'user'; }).length; 428 - var shouldInline = userCount === 1 && display === 'inline' && !errMsg; 342 + function renderTalentEvent(event) { 343 + var eventName = String(event.event || '').trim(); 344 + var card; 345 + var body; 346 + var details; 347 + var summary; 348 + var pre; 429 349 430 - if (panelOpen) { 431 - renderMessages(); 432 - msgArea.scrollTop = msgArea.scrollHeight; 433 - } else if (shouldInline) { 434 - thinking.style.display = 'none'; 435 - if (errMsg) { 436 - inlineResp.textContent = content; 437 - } else { 438 - inlineResp.innerHTML = renderAgentContent(content); 439 - } 440 - inlineResp.style.display = content ? '' : 'none'; 441 - dismissBtn.classList.toggle('visible', !!content); 442 - if (!content) appBar.classList.remove('app-bar--glance'); 443 - } else { 444 - thinking.style.display = 'none'; 445 - appBar.classList.remove('app-bar--glance'); 446 - openPanel(); 447 - } 448 - save(); 449 - recoverCleanup(); 450 - } 350 + if (eventName === 'thinking') { 351 + card = createTimelineCard('thinking', event.ts, 'talent-view-event--thinking'); 352 + body = document.createElement('div'); 353 + body.className = 'talent-view-thinking'; 354 + body.innerHTML = '<em>' + escapeHtml(event.content || '') + '</em>'; 355 + card.appendChild(body); 356 + return card; 357 + } 451 358 452 - // Subscribe to WS first, then check GET 453 - if (window.appEvents) { 454 - recoveryCleanup = window.appEvents.listen('cortex', function(msg) { 455 - if (msg.use_id === agentId) { 456 - if (recoveryWatchdog) { clearTimeout(recoveryWatchdog); recoveryWatchdog = null; } 457 - recoveryWatchdog = setTimeout(function() { 458 - recoverDeliver('', 'panel', 'request timed out. the server took too long to respond. try a shorter question, or check if solstone services are running.'); 459 - }, 180000); 460 - } 461 - if (msg.use_id === agentId && msg.event === 'finish') { 462 - var resp = msg.result || ''; 463 - recoverDeliver(resp, msg.display || 'panel', null); 464 - } else if (msg.use_id === agentId && msg.event === 'error') { 465 - recoverDeliver('', 'panel', 'something went wrong. the server returned an unexpected response. try sending your message again, or check the health page if it keeps happening.'); 466 - } 467 - }); 468 - } 359 + if (eventName === 'tool_start' || eventName === 'tool_end') { 360 + card = createTimelineCard(eventName === 'tool_start' ? 'tool start' : 'tool result', event.ts, 'talent-view-event--tool'); 361 + details = document.createElement('details'); 362 + summary = document.createElement('summary'); 363 + summary.textContent = (event.tool || 'tool') + (event.call_id ? ' (' + event.call_id + ')' : ''); 364 + details.appendChild(summary); 365 + pre = document.createElement('pre'); 366 + pre.className = 'talent-view-pre'; 367 + pre.textContent = eventName === 'tool_start' ? describeValue(event.args) : describeValue(event.result); 368 + details.appendChild(pre); 369 + card.appendChild(details); 370 + return card; 371 + } 469 372 470 - // Start watchdog for recovery 471 - recoveryWatchdog = setTimeout(function() { 472 - recoverDeliver('', 'panel', 'request timed out. the server took too long to respond. try a shorter question, or check if solstone services are running.'); 473 - }, 180000); 373 + if (eventName === 'finish') { 374 + card = createTimelineCard('finished', event.ts, 'talent-view-event--finish'); 375 + pre = document.createElement('pre'); 376 + pre.className = 'talent-view-pre'; 377 + pre.textContent = event.summary || event.result || ''; 378 + card.appendChild(pre); 379 + return card; 380 + } 474 381 475 - // Check GET immediately in case agent already finished 476 - try { 477 - var r = await fetch('/api/chat/result/' + agentId); 478 - if (r.ok) { 479 - // Agent already finished — cancel WS subscription and display 480 - var data = await r.json(); 481 - if (data.state === 'finished') { 482 - if (recoveryCleanup) { recoveryCleanup(); recoveryCleanup = null; } 483 - if (recoveryWatchdog) { clearTimeout(recoveryWatchdog); recoveryWatchdog = null; } 484 - recoverDeliver(data.summary || '', data.display || 'panel', null); 485 - } else if (data.state === 'errored') { 486 - if (recoveryCleanup) { recoveryCleanup(); recoveryCleanup = null; } 487 - if (recoveryWatchdog) { clearTimeout(recoveryWatchdog); recoveryWatchdog = null; } 488 - recoverDeliver('', 'panel', data.reason || 'something went wrong. the server returned an unexpected response. try sending your message again, or check the health page if it keeps happening.'); 489 - } 490 - } 491 - // If 404, agent still running — keep WS subscription active 492 - } catch (err) { 493 - // Network error — keep WS subscription active 494 - } 495 - })(); 496 - } else { 497 - // Stale pending request — abandon 498 - pendingMessage = null; 499 - save(); 500 - } 382 + if (eventName === 'error') { 383 + card = createTimelineCard('error', event.ts, 'talent-view-event--error'); 384 + pre = document.createElement('pre'); 385 + pre.className = 'talent-view-pre'; 386 + pre.textContent = event.error || ''; 387 + card.appendChild(pre); 388 + return card; 501 389 } 390 + 391 + card = createTimelineCard(eventName || 'event', event.ts, ''); 392 + body = document.createElement('div'); 393 + body.className = 'talent-view-info'; 394 + body.textContent = event.talent || event.model || event.provider || describeValue(event); 395 + card.appendChild(body); 396 + return card; 502 397 } 503 398 504 - // --- Dismiss inline response --- 505 - dismissBtn.addEventListener('click', function(e) { 506 - e.stopPropagation(); 507 - appBar.classList.remove('app-bar--glance'); 508 - thinking.style.display = 'none'; 509 - inlineResp.style.display = 'none'; 510 - dismissBtn.classList.remove('visible'); 511 - }); 399 + function renderTalentView(data) { 400 + if (!modalTitle || !modalStatus || !modalTimeline) return; 401 + modalTitle.textContent = data.task || 'Talent run'; 402 + modalTitle.title = data.task || ''; 403 + modalStatus.textContent = data.status || ''; 404 + modalStatus.dataset.status = data.status || ''; 405 + modalTimeline.innerHTML = ''; 512 406 513 - // --- Enter to submit, shift+enter for newline --- 514 - input.addEventListener('keydown', function(e) { 515 - if (e.key === 'Enter' && !e.shiftKey) { 516 - e.preventDefault(); 517 - form.requestSubmit(); 407 + if (!Array.isArray(data.events) || !data.events.length) { 408 + var empty = document.createElement('p'); 409 + empty.className = 'talent-view-empty'; 410 + empty.textContent = 'No events yet.'; 411 + modalTimeline.appendChild(empty); 412 + return; 518 413 } 519 - }); 520 414 521 - function getProgressLabel(msg) { 522 - if (!msg) return null; 523 - if (msg.event === 'thinking') return 'reasoning\u2026'; 524 - if (msg.event === 'tool_start') { 525 - var cmd = (msg.args && msg.args.command) || ''; 526 - if (!cmd && msg.args) { 527 - Object.values(msg.args).some(function(value) { 528 - if (typeof value === 'string') { 529 - cmd = value; 530 - return true; 531 - } 532 - return false; 533 - }); 534 - } 415 + data.events.forEach(function(event) { 416 + modalTimeline.appendChild(renderTalentEvent(event)); 417 + }); 418 + } 419 + 420 + function renderTalentViewError(message) { 421 + if (!modalTitle || !modalStatus || !modalTimeline) return; 422 + modalTitle.textContent = 'Talent run'; 423 + modalStatus.textContent = 'errored'; 424 + modalStatus.dataset.status = 'errored'; 425 + modalTimeline.innerHTML = ''; 426 + var card = createTimelineCard('error', null, 'talent-view-event--error'); 427 + var pre = document.createElement('pre'); 428 + pre.className = 'talent-view-pre'; 429 + pre.textContent = message; 430 + card.appendChild(pre); 431 + modalTimeline.appendChild(card); 432 + } 535 433 536 - if (cmd.includes('sol call journal search')) { 537 - var query = extractArg(cmd, '--query'); 538 - if (query) return 'searching: ' + query.substring(0, 50) + '\u2026'; 539 - return 'searching your journal\u2026'; 540 - } 541 - if (cmd.includes('sol call activities list')) return 'looking up activities\u2026'; 542 - if (cmd.includes('sol call navigate')) return 'navigating\u2026'; 543 - if (cmd.includes('sol call transcripts')) return 'reading transcripts\u2026'; 544 - if (cmd.includes('sol call entity') || cmd.includes('sol call entities')) { 545 - var entityDetail = extractArg(cmd, '--query') 546 - || extractArg(cmd, 'intelligence') 547 - || extractArg(cmd, 'observations') 548 - || extractArg(cmd, 'observe'); 549 - if (entityDetail) return 'looking up: ' + entityDetail.substring(0, 50) + '\u2026'; 550 - } 551 - if (cmd.includes('sol call journal read')) { 552 - var identifier = extractArg(cmd, '--id') || extractArg(cmd, '--date'); 553 - if (identifier) return 'reading: ' + identifier.substring(0, 50) + '\u2026'; 554 - return 'reading your notes\u2026'; 555 - } 556 - if (cmd.includes('sol call journal agents')) return 'checking daily insights\u2026'; 557 - return 'working\u2026'; 434 + async function fetchTalentView(useId) { 435 + var response = await fetch('/api/chat/talent-log/' + encodeURIComponent(useId)); 436 + if (!response.ok) { 437 + var body = {}; 438 + try { 439 + body = await response.json(); 440 + } catch (_err) {} 441 + throw new Error(body.error || 'Unable to load talent run'); 558 442 } 559 - return null; 443 + return response.json(); 560 444 } 561 445 562 - function extractArg(cmd, flag) { 563 - if (!cmd || !flag) return ''; 564 - var singleQuoted = cmd.match(new RegExp(flag + "\\s+'([^']+)'")); 565 - if (singleQuoted && singleQuoted[1]) return singleQuoted[1]; 566 - var doubleQuoted = cmd.match(new RegExp(flag + '\\s+"([^"]+)"')); 567 - if (doubleQuoted && doubleQuoted[1]) return doubleQuoted[1]; 568 - var unquoted = cmd.match(new RegExp(flag + '\\s+(\\S+)')); 569 - if (unquoted && unquoted[1]) return unquoted[1]; 570 - return ''; 446 + function scheduleTalentPoll(useId) { 447 + if (modalUseId !== useId) return; 448 + modalPollTimer = window.setTimeout(function() { 449 + modalPollTimer = null; 450 + refreshTalentView(useId); 451 + }, 2000); 571 452 } 572 453 573 - var announceTimer = null; 574 - 575 - function updateThinkingLabel(text) { 576 - var barEl = document.getElementById('chatBarThinking'); 577 - var panelEl = document.getElementById('panelThinking'); 578 - [barEl, panelEl].forEach(function(el) { 579 - if (!el) return; 580 - var visual = el.querySelector('[aria-hidden="true"]'); 581 - var announce = el.querySelector('.thinking-announce'); 582 - if (!visual) { 583 - visual = document.createElement('span'); 584 - visual.setAttribute('aria-hidden', 'true'); 585 - el.innerHTML = ''; 586 - el.appendChild(visual); 454 + function attachTalentViewChat(useId) { 455 + if (modalChatCleanup || !window.appEvents) return; 456 + modalChatCleanup = window.appEvents.listen('chat', function(msg) { 457 + if (String(msg.use_id || '') !== useId) return; 458 + var eventName = String(msg.event || msg.kind || ''); 459 + if (eventName === 'talent_finished' || eventName === 'talent_errored') { 460 + refreshTalentView(useId); 587 461 } 588 - if (!announce) { 589 - announce = document.createElement('span'); 590 - announce.className = 'visually-hidden thinking-announce'; 591 - el.appendChild(announce); 592 - } 593 - visual.style.opacity = '0'; 594 - visual.innerHTML = '<span class="chat-bar-thinking-dot"></span> ' + text; 595 - requestAnimationFrame(function() { visual.style.opacity = '1'; }); 596 462 }); 597 - if (announceTimer) clearTimeout(announceTimer); 598 - announceTimer = setTimeout(function() { 599 - var delayedBarEl = document.getElementById('chatBarThinking'); 600 - var delayedPanelEl = document.getElementById('panelThinking'); 601 - [delayedBarEl, delayedPanelEl].forEach(function(el) { 602 - if (!el) return; 603 - var announce = el.querySelector('.thinking-announce'); 604 - if (announce) announce.textContent = text; 605 - }); 606 - }, 500); 607 463 } 608 464 609 - // --- Form submit --- 610 - form.onsubmit = async function(e) { 611 - e.preventDefault(); 612 - var text = input.value.trim(); 613 - if (!text) return; 614 - 615 - messages.push({ role: 'user', content: text, ts: Date.now() }); 616 - 617 - // Clear input 618 - input.value = ''; 619 - input.style.height = ''; 620 - sendBtn.classList.remove('visible'); 621 - input.disabled = true; 622 - save(); 623 - 624 - if (panelOpen) { 625 - // Show user message + thinking indicator in panel 626 - renderMessages(); 627 - var td = document.createElement('div'); 628 - td.className = 'conversation-message agent thinking'; 629 - td.id = 'panelThinking'; 630 - td.setAttribute('aria-live', 'assertive'); 631 - td.innerHTML = '<span aria-hidden="true"><span class="chat-bar-thinking-dot"></span> thinking\u2026</span><span class="visually-hidden thinking-announce">thinking\u2026</span>'; 632 - msgArea.appendChild(td); 633 - msgArea.scrollTop = msgArea.scrollHeight; 634 - } else { 635 - // Show thinking in inline response area 636 - thinking.style.display = ''; 637 - inlineResp.style.display = 'none'; 638 - dismissBtn.classList.remove('visible'); 639 - appBar.classList.add('app-bar--glance'); 465 + async function refreshTalentView(useId) { 466 + if (!useId || modalUseId !== useId) return; 467 + try { 468 + var data = await fetchTalentView(useId); 469 + if (modalUseId !== useId) return; 470 + renderTalentView(data); 471 + if (data.status === 'running') { 472 + stopTalentLiveUpdates(); 473 + attachTalentViewChat(useId); 474 + scheduleTalentPoll(useId); 475 + } else { 476 + stopTalentLiveUpdates(); 477 + } 478 + } catch (err) { 479 + stopTalentLiveUpdates(); 480 + renderTalentViewError(err && err.message ? err.message : 'Unable to load talent run'); 640 481 } 482 + } 641 483 642 - var cleanupCortex = null; 643 - var timerInterval = null; 644 - var timerTimeout = null; 645 - var watchdogTimer = null; 646 - var thinkingStarted = Date.now(); 484 + window.openTalentView = function(useId, _opts) { 485 + if (!modal || !useId) return; 486 + modalTrigger = document.activeElement; 487 + modalUseId = String(useId); 488 + stopTalentLiveUpdates(); 489 + modalStatus.textContent = ''; 490 + modalStatus.dataset.status = ''; 491 + modalTitle.textContent = 'Loading...'; 492 + modalTimeline.innerHTML = ''; 493 + showTalentView(); 494 + refreshTalentView(modalUseId); 495 + }; 647 496 648 - function cleanup() { 649 - if (cleanupCortex) { cleanupCortex(); cleanupCortex = null; } 650 - if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } 651 - if (timerTimeout) { clearTimeout(timerTimeout); timerTimeout = null; } 652 - if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; } 653 - if (announceTimer) { clearTimeout(announceTimer); announceTimer = null; } 654 - var pt = document.getElementById('panelThinking'); 655 - if (pt) pt.remove(); 656 - thinking.style.display = 'none'; 657 - input.disabled = false; 658 - input.focus(); 497 + window.openConversation = function(text) { 498 + if (!input) return; 499 + if (typeof text === 'string' && text) { 500 + input.value = text; 501 + resizeComposer(); 659 502 } 503 + input.focus(); 504 + input.selectionStart = input.selectionEnd = input.value.length; 505 + }; 660 506 661 - function deliverResult(resp, display, errMsg) { 662 - var content = errMsg || resp; 663 - if (content) { 664 - messages.push({ role: errMsg ? 'error' : 'agent', content: content, ts: Date.now() }); 507 + function handleChatEvent(msg) { 508 + var eventName = String(msg.event || msg.kind || ''); 509 + if (eventName === 'owner_message') { 510 + setPendingState(false); 511 + return; 512 + } 513 + if (eventName === 'sol_message') { 514 + setStatus(msg.text || '', msg.notes || msg.text || ''); 515 + return; 516 + } 517 + if (eventName === 'talent_spawned') { 518 + upsertTalent({ 519 + useId: String(msg.use_id || ''), 520 + name: msg.name || '', 521 + task: msg.task || '', 522 + status: 'active', 523 + updatedAt: msg.started_at || msg.ts || Date.now() 524 + }); 525 + return; 526 + } 527 + if (eventName === 'talent_finished') { 528 + upsertTalent({ 529 + useId: String(msg.use_id || ''), 530 + name: msg.name || '', 531 + status: 'completed', 532 + updatedAt: msg.ts || Date.now() 533 + }); 534 + if (modalUseId && modalUseId === String(msg.use_id || '')) { 535 + refreshTalentView(modalUseId); 665 536 } 666 - pendingMessage = null; 537 + return; 538 + } 539 + if (eventName === 'talent_errored') { 540 + upsertTalent({ 541 + useId: String(msg.use_id || ''), 542 + name: msg.name || '', 543 + status: 'errored', 544 + updatedAt: msg.ts || Date.now() 545 + }); 546 + if (modalUseId && modalUseId === String(msg.use_id || '')) { 547 + refreshTalentView(modalUseId); 548 + } 549 + return; 550 + } 551 + if (eventName === 'chat_error') { 552 + setStatus('chat had trouble — try again', 'chat had trouble — try again'); 553 + } 554 + } 667 555 668 - // Multi-turn conversations always use panel — after the first exchange, 669 - // the conversation has enough context that panel view is more appropriate. 670 - var userCount = messages.filter(function(m) { return m.role === 'user'; }).length; 671 - var shouldInline = userCount === 1 && display === 'inline' && !errMsg; 672 - 673 - if (panelOpen) { 674 - renderMessages(); 675 - msgArea.scrollTop = msgArea.scrollHeight; 676 - } else if (shouldInline) { 677 - if (errMsg) { 678 - inlineResp.textContent = content; 679 - } else { 680 - inlineResp.innerHTML = renderAgentContent(content); 681 - } 682 - inlineResp.style.display = content ? '' : 'none'; 683 - dismissBtn.classList.toggle('visible', !!content); 684 - if (!content) appBar.classList.remove('app-bar--glance'); 685 - } else { 686 - appBar.classList.remove('app-bar--glance'); 687 - openPanel(); 556 + async function hydrateChatBar() { 557 + if (!appBar) return; 558 + try { 559 + var response = await fetch('/api/chat/session'); 560 + if (!response.ok) throw new Error('session'); 561 + var data = await response.json(); 562 + if (data.latest_sol_message) { 563 + setStatus( 564 + data.latest_sol_message.text || '', 565 + data.latest_sol_message.notes || data.latest_sol_message.text || '' 566 + ); 688 567 } 689 - save(); 690 - cleanup(); 568 + (data.active_talents || []).forEach(function(talent) { 569 + upsertTalent({ 570 + useId: String(talent.use_id || ''), 571 + name: talent.name || '', 572 + task: talent.task || '', 573 + status: 'active', 574 + updatedAt: talent.started_at || Date.now() 575 + }); 576 + }); 577 + } catch (_err) { 578 + setStatus('', ''); 691 579 } 580 + } 692 581 693 - function startWatchdog(agentId) { 694 - if (watchdogTimer) clearTimeout(watchdogTimer); 695 - watchdogTimer = setTimeout(function() { 696 - deliverResult('', 'panel', 'request timed out. the server took too long to respond. try a shorter question, or check if solstone services are running.'); 697 - }, 180000); 698 - } 582 + async function handleSubmit(event) { 583 + event.preventDefault(); 584 + if (!input || !sendBtn || pendingSend) return; 585 + var message = input.value.trim(); 586 + if (!message) return; 699 587 588 + setPendingState(true); 700 589 try { 701 - var r = await fetch('/api/chat', { 590 + var response = await fetch('/api/chat', { 702 591 method: 'POST', 703 592 headers: { 'Content-Type': 'application/json' }, 704 593 body: JSON.stringify({ 705 - message: text, 706 - app: '{{ app }}', 594 + message: message, 595 + app: APP_NAME, 707 596 path: window.location.pathname, 708 597 facet: window.selectedFacet || null 709 598 }) 710 599 }); 600 + if (!response.ok) throw new Error('request failed'); 601 + input.value = ''; 602 + resizeComposer(); 603 + } catch (_err) { 604 + setStatus('chat had trouble — try again', 'chat had trouble — try again'); 605 + } finally { 606 + setPendingState(false); 607 + input.focus(); 608 + } 609 + } 711 610 712 - if (!r.ok) { 713 - deliverResult('', 'panel', 'something went wrong. the server returned an unexpected response. try sending your message again, or check the health page if it keeps happening.'); 714 - return; 715 - } 611 + function initChatChrome() { 612 + try { 613 + LEGACY_KEYS.forEach(function(key) { localStorage.removeItem(key); }); 614 + } catch (_err) {} 716 615 717 - var data = await r.json(); 718 - var agentId = data.use_id; 719 - if (!agentId) { 720 - deliverResult('', 'panel', 'something went wrong. the server returned an unexpected response. try sending your message again, or check the health page if it keeps happening.'); 721 - return; 722 - } 616 + if (modal) { 617 + modal.addEventListener('click', function(event) { 618 + if (event.target.closest('[data-action="close"]')) { 619 + hideTalentView(); 620 + } 621 + }); 622 + } 723 623 724 - pendingMessage = { text: text, sentAt: Date.now(), agentId: agentId }; 725 - save(); 624 + if (input) { 625 + resizeComposer(); 626 + input.addEventListener('input', resizeComposer); 627 + } 726 628 727 - // Start elapsed timer 728 - timerTimeout = setTimeout(function() { 729 - timerInterval = setInterval(function() { 730 - var elapsed = Math.round((Date.now() - thinkingStarted) / 1000); 731 - var suffix = ' <span class="thinking-elapsed" aria-hidden="true">' + elapsed + 's</span>'; 732 - var barEl = document.getElementById('chatBarThinking'); 733 - var panelEl = document.getElementById('panelThinking'); 734 - [barEl, panelEl].forEach(function(el) { 735 - if (!el) return; 736 - var visual = el.querySelector('[aria-hidden="true"]'); 737 - if (!visual) visual = el; 738 - var existing = visual.querySelector('.thinking-elapsed'); 739 - if (existing) { 740 - existing.textContent = elapsed + 's'; 741 - } else { 742 - visual.insertAdjacentHTML('beforeend', suffix); 743 - requestAnimationFrame(function() { 744 - var el = visual.querySelector('.thinking-elapsed'); 745 - if (el) el.style.opacity = '0.6'; 746 - }); 747 - } 748 - }); 749 - }, 1000); 750 - }, 5000); 629 + if (form) { 630 + form.addEventListener('submit', handleSubmit); 631 + } 751 632 752 - // Start inactivity watchdog 753 - startWatchdog(agentId); 633 + if (window.appEvents) { 634 + window.appEvents.listen('chat', handleChatEvent); 635 + } 754 636 755 - // Subscribe to cortex events for this agent 756 - if (window.appEvents) { 757 - cleanupCortex = window.appEvents.listen('cortex', function(msg) { 758 - // Reset inactivity watchdog and update thinking label for our agent 759 - if (msg.use_id === agentId) { 760 - var label = getProgressLabel(msg); 761 - if (label) updateThinkingLabel(label); 762 - startWatchdog(agentId); 763 - } 637 + hydrateChatBar(); 638 + } 764 639 765 - // Handle finish/error for our agent 766 - if (msg.use_id === agentId && msg.event === 'finish') { 767 - var resp = msg.result || ''; 768 - deliverResult(resp, msg.display || 'panel', null); 769 - } else if (msg.use_id === agentId && msg.event === 'error') { 770 - deliverResult('', 'panel', 'something went wrong. the server returned an unexpected response. try sending your message again, or check the health page if it keeps happening.'); 771 - } 772 - }); 773 - } 774 - } catch (err) { 775 - deliverResult('', 'panel', 'connection lost. couldn\'t reach the server. try sending your message again, or check if solstone services are running.'); 776 - } 777 - }; 640 + if (document.readyState === 'loading') { 641 + document.addEventListener('DOMContentLoaded', initChatChrome); 642 + } else { 643 + initChatChrome(); 644 + } 778 645 })(); 779 646 </script> 780 647
+16
convey/templates/chat_bar.html
··· 1 + <div id="appBar" class="app-bar" role="region" aria-label="chat"> 2 + <div id="chatBarStatus" class="chat-bar-status" aria-live="polite"> 3 + <span id="chatBarStatusText"></span> 4 + </div> 5 + <div id="chatBarTalents" class="chat-bar-talents" aria-label="active talents"></div> 6 + <form id="chatBarForm" class="chat-bar-form"> 7 + <textarea 8 + id="chatBarInput" 9 + class="chat-bar-input" 10 + rows="1" 11 + placeholder="{{ chat_bar_placeholder }}" 12 + aria-label="chat input" 13 + ></textarea> 14 + <button id="chatBarSend" type="submit" class="chat-bar-send" aria-label="send">Send</button> 15 + </form> 16 + </div>
-32
convey/templates/conversation_panel.html
··· 1 - <!-- Conversation Backdrop (focus mode — blurs convey content behind panel) --> 2 - <div class="conversation-backdrop" id="conversationBackdrop"></div> 3 - 4 - <!-- Chat Bar / Conversation Panel --> 5 - <div class="app-bar" id="appBar" role="region" aria-label="conversation"> 6 - <!-- Conversation messages (visible in panel mode) --> 7 - <div class="conversation-messages" id="conversationMessages" aria-live="polite" tabindex="0"></div> 8 - 9 - <!-- Inline response (visible in bar mode) --> 10 - <div class="chat-bar-response-panel" id="chatBarResponsePanel"> 11 - <div class="chat-bar-thinking" id="chatBarThinking" style="display: none;" aria-live="assertive"> 12 - <span aria-hidden="true"><span class="chat-bar-thinking-dot"></span> thinking&hellip;</span> 13 - <span class="visually-hidden thinking-announce">thinking&hellip;</span> 14 - </div> 15 - <div class="chat-bar-response" id="chatBarResponse" style="display: none;"></div> 16 - <button class="chat-bar-dismiss" id="chatBarDismiss" title="dismiss" aria-label="close conversation">&times;</button> 17 - <div class="chat-bar-separator"></div> 18 - </div> 19 - 20 - <!-- Separator between messages and input (panel mode) --> 21 - <div class="conversation-separator"></div> 22 - 23 - <!-- Input row --> 24 - <div class="chat-bar-input-row"> 25 - <button id="chatExpandBtn" class="chat-bar-expand" aria-label="expand conversation" aria-expanded="false">⤢</button> 26 - <form id="chatInputForm" style="display: contents;"> 27 - <textarea id="chatMessageInput" class="chat-bar-input" aria-label="message your solstone" rows="1" 28 - placeholder="{{ chat_bar_placeholder|default('send a message...') }}"></textarea> 29 - <button type="submit" class="chat-bar-send" id="chatBarSend" title="send" aria-label="send message"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button> 30 - </form> 31 - </div> 32 - </div>
+44
tests/test_no_legacy_chat_imports.py
··· 11 11 ROOT / "apps/sol/maint/006_rename_unified_triage_providers.py", 12 12 ROOT / "tests/test_maint_006_rename_unified_triage_providers.py", 13 13 } 14 + FORBIDDEN_CHAT_LITERALS = { 15 + "conversationBackdrop", 16 + "conversationMessages", 17 + "chatBarResponsePanel", 18 + "chatBarThinking", 19 + "chatBarResponse", 20 + "chatBarDismiss", 21 + "conversation-backdrop", 22 + "conversation-messages", 23 + "conversation-separator", 24 + "solstone:conversationState", 25 + "solstone:chatBarState", 26 + "panelFocusTrapHandler", 27 + "openPanel", 28 + "closePanel", 29 + "_closeConversationPanel", 30 + } 14 31 15 32 16 33 def _parts(*pieces: str) -> str: ··· 42 59 ] 43 60 44 61 62 + def _text_scan_files() -> list[Path]: 63 + blocked_parts = {"tests/fixtures", ".venv", "node_modules", "__pycache__"} 64 + files: list[Path] = [] 65 + for pattern in ("*.html", "*.js"): 66 + for path in ROOT.rglob(pattern): 67 + path_str = str(path) 68 + if any(part in path_str for part in blocked_parts): 69 + continue 70 + files.append(path) 71 + return files 72 + 73 + 45 74 def _parse(path: Path) -> ast.Module | None: 46 75 try: 47 76 return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) ··· 88 117 violations.append(str(path)) 89 118 90 119 assert violations == [] 120 + 121 + 122 + def test_no_legacy_chat_dom_literals_in_templates_or_js(): 123 + violations: list[str] = [] 124 + this_file = Path(__file__).resolve() 125 + 126 + for path in _text_scan_files(): 127 + if path.resolve() == this_file: 128 + continue 129 + content = path.read_text(encoding="utf-8") 130 + for literal in FORBIDDEN_CHAT_LITERALS: 131 + if literal in content: 132 + violations.append(f"{path}: {literal}") 133 + 134 + assert violations == []