You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

8790 lines
304 KiB

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Ghostfolio AI Agent</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0a0d14;
--surface: #111520;
--surface2: #181e2e;
--border: #1f2840;
--border2: #2a3550;
--indigo: #6366f1;
--indigo2: #818cf8;
--indigo-bg: #1a1d3a;
--green: #22c55e;
--yellow: #f59e0b;
--red: #ef4444;
--text: #e2e8f0;
--text2: #94a3b8;
--text3: #475569;
--radius: 12px;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
header {
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
z-index: 10;
}
.logo {
width: 34px;
height: 34px;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
flex-shrink: 0;
}
.header-titles h1 {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.header-titles p {
font-size: 11px;
color: var(--text3);
margin-top: 1px;
}
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 16px;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.latency-chip {
font-size: 11px;
color: var(--text3);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 9px;
transition: opacity 0.2s;
cursor: default;
user-select: none;
}
.latency-chip.hidden {
opacity: 0;
pointer-events: none;
}
.user-badge {
display: flex;
align-items: center;
gap: 7px;
}
.user-avatar {
width: 28px;
height: 28px;
border-radius: 7px;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
letter-spacing: 0.5px;
}
.user-name {
font-size: 12px;
color: var(--text2);
}
.clear-btn {
font-size: 12px;
padding: 5px 12px;
border-radius: 8px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text2);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.clear-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
/* ── Session summary toast ── */
.session-toast {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%) translateY(-10px);
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 10px 18px;
font-size: 12px;
color: var(--text2);
z-index: 100;
opacity: 0;
pointer-events: none;
transition: all 0.3s;
white-space: nowrap;
}
.session-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ── Chat area ── */
.chat-area {
flex: 1;
overflow-y: auto;
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ── Empty state ── */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
color: var(--text3);
text-align: center;
padding-bottom: 40px;
}
.empty-icon {
width: 56px;
height: 56px;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.35);
}
.empty-state h2 {
font-size: 18px;
color: var(--text2);
font-weight: 600;
}
.empty-state p {
font-size: 13px;
max-width: 320px;
line-height: 1.6;
}
.quick-grid {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 560px;
}
.quick-category {
display: flex;
flex-direction: column;
gap: 6px;
}
.quick-cat-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text3);
padding-left: 2px;
}
.quick-row {
display: flex;
gap: 8px;
}
.quick-btn {
flex: 1;
font-size: 12px;
padding: 8px 12px;
border-radius: 9px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
cursor: pointer;
transition: all 0.15s;
text-align: left;
line-height: 1.4;
}
.quick-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.quick-btn .qb-icon {
display: block;
margin-bottom: 3px;
font-size: 14px;
}
.quick-btn .qb-title {
font-weight: 600;
display: block;
font-size: 12px;
}
.quick-btn .qb-sub {
font-size: 10px;
color: var(--text3);
display: block;
margin-top: 1px;
}
/* ── Messages ── */
.message {
display: flex;
flex-direction: column;
max-width: 740px;
}
.message.user {
align-self: flex-end;
align-items: flex-end;
}
.message.agent {
align-self: flex-start;
align-items: flex-start;
}
.bubble {
padding: 11px 15px;
border-radius: 14px;
font-size: 13.5px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
position: relative;
}
.message.user .bubble {
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.agent .bubble {
background: var(--surface2);
color: var(--text);
border-bottom-left-radius: 4px;
border: 1px solid var(--border2);
}
/* Copy button on hover */
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
min-width: 26px;
height: 26px;
padding: 0 6px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 12px;
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
}
.message.agent .bubble:hover .copy-btn {
opacity: 1;
}
.copy-btn:hover {
color: var(--indigo2);
border-color: var(--indigo);
}
.copy-btn.copied {
color: var(--green);
border-color: var(--green);
opacity: 1;
}
.copy-btn .copy-label {
font-size: 11px;
}
/* ── Message footer (badges, confidence, timestamp) ── */
.msg-footer {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 5px;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
}
.badge {
font-size: 11px;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--border2);
color: var(--text2);
background: var(--surface);
display: inline-flex;
align-items: center;
gap: 4px;
cursor: default;
user-select: none;
}
.badge.tool {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.badge.pass {
border-color: var(--green);
color: #86efac;
background: #052e16;
}
.badge.flag {
border-color: var(--yellow);
color: #fcd34d;
background: #1c1205;
}
.badge.fail {
border-color: var(--red);
color: #fca5a5;
background: #1c0505;
}
.badge.time {
color: var(--text3);
}
/* Confidence bar */
.confidence-bar-wrap {
display: flex;
align-items: center;
gap: 7px;
}
.confidence-bar-label {
font-size: 10px;
color: var(--text3);
white-space: nowrap;
}
.confidence-bar-track {
width: 80px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.confidence-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.confidence-bar-fill.high {
background: var(--green);
}
.confidence-bar-fill.med {
background: var(--yellow);
}
.confidence-bar-fill.low {
background: var(--red);
}
/* Timestamp */
.msg-ts {
font-size: 10px;
color: var(--text3);
}
/* Retry button */
.retry-btn {
font-size: 11px;
padding: 4px 10px;
border-radius: 7px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text3);
cursor: pointer;
transition: all 0.15s;
margin-top: 4px;
align-self: flex-start;
}
.retry-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
/* Confirmation banner */
.confirm-banner {
background: #1c1205;
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 9px;
padding: 8px 12px;
font-size: 12px;
color: #fcd34d;
margin-top: 6px;
}
/* ── Debug panel ── */
.debug-panel {
margin-top: 6px;
width: 100%;
}
.debug-panel summary {
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text3);
padding: 2px 0;
}
.debug-panel summary::-webkit-details-marker {
display: none;
}
.debug-body {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 11px;
padding: 8px 12px;
background: #07090f;
color: var(--text);
border-radius: 6px;
margin-top: 4px;
border: 1px solid var(--border);
overflow-x: auto;
line-height: 1.7;
}
.db-row {
display: flex;
gap: 8px;
}
.db-key {
color: var(--indigo2);
min-width: 110px;
}
.db-val {
color: var(--text2);
}
.db-val.pass {
color: var(--green);
}
.db-val.flag {
color: var(--yellow);
}
.db-val.fail {
color: var(--red);
}
.db-val.high {
color: var(--green);
}
.db-val.med {
color: var(--yellow);
}
.db-val.low {
color: var(--red);
}
/* ── Live thinking panel ── */
.thinking-panel {
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 480px;
align-self: flex-start;
}
.thinking-header {
font-size: 11px;
color: var(--text3);
font-weight: 500;
letter-spacing: 0.3px;
}
.step-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.step-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text2);
}
.step-icon {
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
flex-shrink: 0;
}
.step-icon.running {
border: 2px solid var(--indigo);
animation: spin 1s linear infinite;
}
.step-icon.done {
background: var(--green);
color: #000;
}
.step-icon.pending {
border: 2px solid var(--border2);
color: var(--text3);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.step-label {
flex: 1;
}
.step-tools {
font-size: 10px;
color: var(--indigo2);
}
/* ── Typing indicator (fallback) ── */
.typing {
display: flex;
gap: 4px;
padding: 12px 16px;
background: var(--surface2);
border-radius: 14px;
border-bottom-left-radius: 4px;
border: 1px solid var(--border2);
width: fit-content;
}
.typing span {
width: 6px;
height: 6px;
background: var(--indigo);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.typing span:nth-child(2) {
animation-delay: 0.2s;
}
.typing span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-5px);
}
}
/* ── Restored session notice ── */
.session-restored {
align-self: center;
font-size: 11px;
color: var(--text3);
background: var(--surface2);
border: 1px dashed var(--border2);
border-radius: 999px;
padding: 4px 14px;
}
/* ── /tools panel ── */
.tools-panel {
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 14px 16px;
align-self: flex-start;
max-width: 540px;
}
.tools-panel h3 {
font-size: 12px;
color: var(--text2);
margin-bottom: 10px;
font-weight: 600;
}
.tool-entry {
display: flex;
gap: 10px;
margin-bottom: 7px;
}
.tool-entry-name {
font-size: 11px;
color: var(--indigo2);
font-family: 'SF Mono', 'Fira Code', monospace;
min-width: 160px;
}
.tool-entry-desc {
font-size: 11px;
color: var(--text2);
line-height: 1.4;
}
/* ── Input area ── */
.input-wrap {
background: var(--surface);
border-top: 1px solid var(--border);
padding: 12px 20px;
flex-shrink: 0;
position: relative;
}
.input-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
textarea {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
color: var(--text);
font-size: 13.5px;
font-family: inherit;
padding: 10px 14px;
resize: none;
min-height: 44px;
max-height: 140px;
outline: none;
transition: border-color 0.15s;
}
textarea:focus {
border-color: var(--indigo);
}
textarea::placeholder {
color: var(--text3);
}
.send-btn {
width: 44px;
height: 44px;
border-radius: var(--radius);
border: none;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
font-size: 18px;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
}
.send-btn:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.06);
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.6);
}
.send-btn:active:not(:disabled) {
transform: scale(0.97);
opacity: 1;
}
.send-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
box-shadow: none;
}
/* ── Markdown content inside bubbles ── */
.bubble h1,
.bubble h2,
.bubble h3 {
font-size: 14px;
font-weight: 700;
color: var(--text);
margin: 10px 0 4px;
}
.bubble h1 {
font-size: 15px;
}
.bubble p {
margin: 4px 0;
}
.bubble ul,
.bubble ol {
padding-left: 18px;
margin: 4px 0;
}
.bubble li {
margin: 2px 0;
line-height: 1.5;
}
.bubble code {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
background: rgba(99, 102, 241, 0.12);
color: var(--indigo2);
padding: 1px 5px;
border-radius: 4px;
}
.bubble pre {
background: #07090f;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
overflow-x: auto;
margin: 6px 0;
}
.bubble pre code {
background: none;
padding: 0;
color: var(--text2);
font-size: 12px;
}
.bubble table {
border-collapse: collapse;
width: 100%;
margin: 6px 0;
font-size: 12px;
}
.bubble th {
background: var(--surface);
color: var(--indigo2);
padding: 5px 10px;
text-align: left;
border: 1px solid var(--border2);
font-weight: 600;
}
.bubble td {
padding: 5px 10px;
border: 1px solid var(--border);
color: var(--text2);
}
.bubble tr:nth-child(even) td {
background: rgba(255, 255, 255, 0.02);
}
.bubble hr {
border: none;
border-top: 1px solid var(--border2);
margin: 8px 0;
}
.bubble blockquote {
border-left: 3px solid var(--indigo);
padding-left: 10px;
color: var(--text2);
margin: 6px 0;
font-style: italic;
}
.bubble strong {
color: var(--text);
}
.bubble a {
color: var(--indigo2);
text-decoration: underline;
}
/* ── TTS speak button ── */
.speak-btn {
position: absolute;
top: 8px;
right: 36px;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
}
.message.agent .bubble:hover .speak-btn {
opacity: 1;
}
.speak-btn:hover {
color: var(--indigo2);
border-color: var(--indigo);
}
.speak-btn.speaking {
color: var(--green);
border-color: var(--green);
opacity: 1;
animation: pulse 1s infinite;
}
/* ── Edit button on user bubbles ── */
.edit-btn {
font-size: 11px;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: transparent;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
margin-top: 4px;
align-self: flex-end;
transition: all 0.15s;
display: none;
}
.message.user:hover .edit-btn {
display: inline-block;
}
.edit-btn:hover {
border-color: rgba(255, 255, 255, 0.3);
color: #fff;
}
/* ── Auto-speak toggle in header ── */
.speak-toggle {
font-size: 11px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text3);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.speak-toggle.on {
border-color: var(--green);
color: #86efac;
background: #052e16;
}
/* ── Session history drawer ── */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.drawer-overlay.open {
opacity: 1;
pointer-events: all;
}
.sessions-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
background: var(--surface);
border-right: 1px solid var(--border);
z-index: 201;
display: flex;
flex-direction: column;
transform: translateX(-100%);
transition: transform 0.22s ease;
}
.sessions-drawer.open {
transform: translateX(0);
}
.drawer-header {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.drawer-header h3 {
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.drawer-close {
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text3);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.drawer-close:hover {
color: var(--text);
border-color: var(--text3);
}
.drawer-new-btn {
margin: 10px 12px;
padding: 8px 12px;
border-radius: 9px;
border: 1px solid var(--indigo);
background: var(--indigo-bg);
color: var(--indigo2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: all 0.15s;
flex-shrink: 0;
}
.drawer-new-btn:hover {
background: var(--indigo);
color: #fff;
border-color: var(--indigo);
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 12px;
}
.session-item {
padding: 9px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
margin-bottom: 2px;
}
.session-item:hover {
background: var(--surface2);
}
.session-item.active {
background: var(--indigo-bg);
border: 1px solid var(--indigo);
}
.session-title {
font-size: 12px;
color: var(--text2);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-meta {
font-size: 10px;
color: var(--text3);
margin-top: 2px;
}
.session-item-actions {
float: right;
opacity: 0;
transition: opacity 0.12s;
}
.session-item:hover .session-item-actions {
opacity: 1;
}
.session-del-btn {
font-size: 11px;
color: var(--text3);
background: transparent;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}
.session-del-btn:hover {
color: var(--red);
}
.drawer-empty {
font-size: 12px;
color: var(--text3);
text-align: center;
padding: 30px 16px;
line-height: 1.6;
}
/* ── Pinned context strip ── */
.context-strip {
background: var(--surface2);
border-bottom: 1px solid var(--border);
padding: 5px 20px;
display: none;
flex-wrap: wrap;
gap: 6px;
align-items: center;
flex-shrink: 0;
}
.context-strip.visible {
display: flex;
}
.context-tag {
font-size: 10px;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--border2);
color: var(--text3);
background: var(--surface);
display: inline-flex;
align-items: center;
gap: 4px;
cursor: default;
user-select: none;
}
.context-tag.active {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.context-tag.green {
border-color: var(--green);
color: #86efac;
background: #052e16;
}
/* ── Conversation search ── */
.search-bar {
position: fixed;
top: 58px;
left: 50%;
transform: translateX(-50%);
background: var(--surface2);
border: 1px solid var(--indigo);
border-radius: var(--radius);
padding: 8px 12px;
display: none;
align-items: center;
gap: 8px;
z-index: 150;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25);
min-width: 300px;
}
.search-bar.open {
display: flex;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.search-input::placeholder {
color: var(--text3);
}
.search-count {
font-size: 11px;
color: var(--text3);
white-space: nowrap;
}
.search-nav {
display: flex;
gap: 3px;
}
.search-nav button {
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text3);
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
}
.search-nav button:hover {
border-color: var(--indigo);
color: var(--indigo2);
}
.search-close {
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text3);
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
mark {
background: rgba(99, 102, 241, 0.35);
color: var(--text);
border-radius: 2px;
padding: 0 1px;
}
mark.current {
background: rgba(245, 158, 11, 0.5);
outline: 1px solid var(--yellow);
}
/* ── Attachment button & menu ── */
.attach-wrap {
position: relative;
flex-shrink: 0;
}
.attach-btn {
width: 44px;
height: 44px;
border-radius: var(--radius);
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.attach-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.attach-menu {
position: absolute;
bottom: 52px;
left: 0;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 6px;
min-width: 200px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
display: none;
flex-direction: column;
gap: 2px;
z-index: 50;
}
.attach-menu.open {
display: flex;
}
.attach-item {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
color: var(--text2);
transition: background 0.12s;
border: none;
background: transparent;
text-align: left;
width: 100%;
}
.attach-item:hover {
background: var(--surface);
color: var(--indigo2);
}
.attach-item-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.attach-preview {
font-size: 11px;
color: var(--indigo2);
background: var(--indigo-bg);
border: 1px solid var(--indigo);
border-radius: 7px;
padding: 4px 10px;
margin: 4px 0 0;
display: none;
align-items: center;
gap: 6px;
}
.attach-preview.visible {
display: flex;
}
.attach-preview-remove {
margin-left: auto;
cursor: pointer;
color: var(--text3);
font-size: 13px;
background: none;
border: none;
}
.attach-preview-remove:hover {
color: var(--red);
}
/* ── Chats button in header ── */
.chats-btn {
font-size: 12px;
padding: 5px 12px;
border-radius: 8px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text2);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px;
}
.chats-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
/* ── Shortcut help modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 500;
display: none;
align-items: center;
justify-content: center;
}
.modal-overlay.open {
display: flex;
}
.modal-box {
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 16px;
padding: 20px 24px;
max-width: 480px;
width: 90vw;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-size: 14px;
font-weight: 700;
color: var(--text);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.modal-close-btn {
margin-left: auto;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text3);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close-btn:hover {
color: var(--text);
border-color: var(--text3);
}
.shortcut-table {
width: 100%;
border-collapse: collapse;
}
.shortcut-table tr {
border-bottom: 1px solid var(--border);
}
.shortcut-table tr:last-child {
border-bottom: none;
}
.shortcut-table td {
padding: 7px 4px;
font-size: 12px;
vertical-align: middle;
}
.shortcut-table td:first-child {
color: var(--text2);
width: 55%;
}
.shortcut-table td:last-child {
text-align: right;
}
.kbd {
display: inline-flex;
align-items: center;
gap: 3px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
padding: 2px 6px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 5px;
color: var(--indigo2);
white-space: nowrap;
}
/* ── Star/favorite button on bubbles ── */
.star-btn {
position: absolute;
top: 8px;
right: 64px;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
}
.message.agent .bubble:hover .star-btn {
opacity: 1;
}
.star-btn:hover {
color: var(--yellow);
border-color: var(--yellow);
}
.star-btn.starred {
color: var(--yellow);
border-color: var(--yellow);
opacity: 1;
}
/* ── Saved tab in drawer ── */
.drawer-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.drawer-tab {
flex: 1;
padding: 8px 4px;
font-size: 11px;
font-weight: 600;
text-align: center;
cursor: pointer;
color: var(--text3);
border-bottom: 2px solid transparent;
transition: all 0.15s;
background: transparent;
border-top: none;
border-left: none;
border-right: none;
}
.drawer-tab.active {
color: var(--indigo2);
border-bottom-color: var(--indigo);
}
.saved-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px 12px;
}
.saved-item {
padding: 9px 10px;
border-radius: 8px;
margin-bottom: 4px;
background: var(--surface2);
border: 1px solid var(--border);
}
.saved-item-text {
font-size: 11px;
color: var(--text2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.saved-item-meta {
font-size: 10px;
color: var(--text3);
margin-top: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.saved-del-btn {
font-size: 11px;
color: var(--text3);
background: transparent;
border: none;
cursor: pointer;
padding: 1px 4px;
}
.saved-del-btn:hover {
color: var(--red);
}
/* ── Response length toggle ── */
.length-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 0 0;
flex-wrap: wrap;
}
.length-label {
font-size: 10px;
color: var(--text3);
}
.length-pills {
display: flex;
gap: 3px;
}
.length-pill {
font-size: 10px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text3);
cursor: pointer;
transition: all 0.12s;
font-weight: 500;
}
.length-pill:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.length-pill.active {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
font-weight: 600;
}
/* ── Scenario mode badge ── */
.scenario-badge {
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--yellow);
color: #fcd34d;
background: #1c1205;
display: none;
align-items: center;
gap: 5px;
cursor: pointer;
white-space: nowrap;
animation: pulse 2s infinite;
}
.scenario-badge.active {
display: flex;
}
/* ── Goal tracker chip ── */
.goal-chip {
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--border2);
color: var(--text3);
background: var(--surface2);
display: none;
align-items: center;
gap: 6px;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.2s;
}
.goal-chip.visible {
display: flex;
}
.goal-bar-track {
width: 48px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.goal-bar-fill {
height: 100%;
background: var(--green);
border-radius: 2px;
transition: width 0.4s;
}
/* ── Settings dropdown ── */
.settings-wrap {
position: relative;
}
.settings-btn {
height: 34px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
transition: all 0.15s;
}
.settings-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.settings-menu {
position: absolute;
top: 38px;
right: 0;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 6px;
min-width: 240px;
max-width: 280px;
max-height: calc(100vh - 80px);
overflow-y: auto;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 400;
display: none;
flex-direction: column;
gap: 2px;
}
.settings-menu.open {
display: flex;
}
.settings-menu::-webkit-scrollbar {
width: 4px;
}
.settings-menu::-webkit-scrollbar-thumb {
background: var(--border2);
border-radius: 2px;
}
.settings-scroll-hint {
font-size: 10px;
color: var(--text3);
text-align: center;
padding: 4px 0 2px;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
color: var(--text2);
transition: background 0.12s;
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.settings-item:hover {
background: var(--surface);
color: var(--indigo2);
}
.settings-item-left {
display: flex;
align-items: center;
gap: 8px;
}
.settings-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.toggle-pill {
width: 28px;
height: 15px;
border-radius: 999px;
background: var(--border2);
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-pill::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 11px;
height: 11px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle-pill.on {
background: var(--indigo);
}
.toggle-pill.on::after {
transform: translateX(13px);
}
/* ── Proactive greeting banner ── */
.greeting-banner {
margin: 12px 20px 0;
background: linear-gradient(135deg, var(--indigo-bg), var(--surface2));
border: 1px solid var(--indigo);
border-radius: var(--radius);
padding: 12px 14px;
display: none;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.greeting-banner.show {
display: flex;
}
.greeting-banner-text {
flex: 1;
font-size: 12px;
color: var(--text2);
line-height: 1.5;
}
.greeting-banner-text strong {
color: var(--text);
}
.greeting-action {
font-size: 11px;
padding: 5px 12px;
border-radius: 7px;
border: 1px solid var(--indigo);
background: var(--indigo);
color: #fff;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: opacity 0.15s, transform 0.1s;
}
.greeting-action:hover {
opacity: 0.85;
transform: scale(0.98);
}
.greeting-dismiss {
color: var(--text3);
background: transparent;
border: none;
cursor: pointer;
font-size: 14px;
flex-shrink: 0;
padding: 2px;
}
/* ── Input template picker ── */
.template-picker {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: var(--surface2);
border: 1px solid var(--indigo);
border-radius: var(--radius);
margin-bottom: 6px;
box-shadow: 0 -8px 24px rgba(99, 102, 241, 0.2);
display: none;
flex-direction: column;
z-index: 50;
overflow: hidden;
}
.template-picker.open {
display: flex;
}
.template-picker-header {
padding: 7px 12px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--indigo2);
border-bottom: 1px solid var(--border);
}
.template-item {
padding: 9px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text2);
transition: background 0.1s;
border-bottom: 1px solid var(--border);
}
.template-item:last-child {
border-bottom: none;
}
.template-item:hover {
background: var(--surface);
color: var(--indigo2);
}
.template-item strong {
color: var(--text);
}
.template-item .tmpl-hint {
font-size: 10px;
color: var(--text3);
margin-top: 2px;
}
/* ── Light theme ── */
[data-theme='light'] {
--bg: #f1f5f9;
--surface: #ffffff;
--surface2: #f8fafc;
--border: #e2e8f0;
--border2: #cbd5e1;
--indigo: #6366f1;
--indigo2: #4f46e5;
--indigo-bg: #eef2ff;
--green: #16a34a;
--yellow: #d97706;
--red: #dc2626;
--text: #0f172a;
--text2: #475569;
--text3: #94a3b8;
}
[data-theme='light'] .message.agent .bubble {
background: #ffffff;
border-color: #e2e8f0;
}
[data-theme='light'] .message.user .bubble {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
[data-theme='light'] .bubble code {
background: #eef2ff;
}
[data-theme='light'] .bubble pre {
background: #f8fafc;
}
[data-theme='light'] .debug-body {
background: #f1f5f9;
}
/* ── Print / PDF export styles ── */
@media print {
header,
.context-strip,
.search-bar,
.input-wrap,
.sessions-drawer,
.drawer-overlay,
.modal-overlay,
.greeting-banner,
.copy-btn,
.speak-btn,
.star-btn,
.edit-btn,
.retry-btn,
.msg-footer,
.debug-panel,
.followup-row,
.session-toast,
.watchlist-banner,
.pinned-strip,
.memory-indicator,
.memory-panel,
.reaction-row,
.annotation-btn,
.pin-bubble-btn,
.help-fab {
display: none !important;
}
.annotation-wrap.open {
display: block !important;
background: #fffde7;
color: #4a3500;
border: 1px solid #d97706;
}
body {
background: #fff !important;
color: #000 !important;
overflow: visible !important;
height: auto !important;
}
.chat-area {
overflow: visible !important;
padding: 16px !important;
}
.bubble {
border: 1px solid #ccc !important;
background: #f9f9f9 !important;
color: #000 !important;
}
.message.user .bubble {
background: #e8eeff !important;
}
}
/* ── Mobile responsive ── */
@media (max-width: 600px) {
header {
padding: 8px 12px;
gap: 8px;
}
.header-titles h1 {
font-size: 13px;
}
.user-name,
.latency-chip,
.speak-toggle {
display: none;
}
.networth-badge {
font-size: 10px;
padding: 2px 7px;
}
.chat-area {
padding: 14px 10px;
gap: 12px;
}
.message {
max-width: 100%;
}
.quick-grid {
max-width: 100%;
}
.quick-row {
flex-direction: column;
}
.input-wrap {
padding: 8px 10px;
}
textarea {
font-size: 14px;
}
.sessions-drawer {
width: 85vw;
}
.search-bar {
min-width: 90vw;
}
}
/* ── Mic button ── */
.mic-btn {
width: 44px;
height: 44px;
border-radius: var(--radius);
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
font-size: 18px;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.mic-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.mic-btn.listening {
border-color: var(--red);
color: var(--red);
background: #1c0505;
animation: pulse-mic 1s ease-in-out infinite;
}
@keyframes pulse-mic {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
/* ── Follow-up suggestion chips ── */
.followup-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
align-self: flex-start;
max-width: 740px;
}
.followup-chip {
font-size: 11px;
padding: 5px 12px;
border-radius: 999px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.followup-chip:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
/* ── Net worth banner ── */
.networth-badge {
font-size: 11px;
color: #86efac;
background: #052e16;
border: 1px solid var(--green);
border-radius: 999px;
padding: 3px 10px;
display: none;
align-items: center;
gap: 4px;
white-space: nowrap;
cursor: default;
}
/* ── Editable session title in header ── */
.session-title-wrap {
display: flex;
flex-direction: column;
}
.session-title-wrap h1 {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.session-subtitle {
font-size: 11px;
color: var(--text3);
margin-top: 1px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-subtitle:hover {
color: var(--indigo2);
}
.session-subtitle .edit-pencil {
opacity: 0;
font-size: 10px;
transition: opacity 0.15s;
}
.session-subtitle:hover .edit-pencil {
opacity: 1;
}
.session-title-input {
font-size: 11px;
background: var(--surface2);
border: 1px solid var(--indigo);
border-radius: 5px;
color: var(--text);
padding: 2px 6px;
outline: none;
width: 200px;
font-family: inherit;
}
/* ── Per-session "..." context menu ── */
.session-ctx-wrap {
position: relative;
}
.session-ctx-btn {
opacity: 0;
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid transparent;
background: transparent;
color: var(--text3);
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
flex-shrink: 0;
}
.session-item:hover .session-ctx-btn {
opacity: 1;
}
.session-ctx-btn:hover {
border-color: var(--border2);
color: var(--text2);
background: var(--surface);
}
.session-ctx-menu {
position: absolute;
top: 24px;
right: 0;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 9px;
padding: 4px;
min-width: 150px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
z-index: 300;
display: none;
flex-direction: column;
}
.session-ctx-menu.open {
display: flex;
}
.ctx-item {
padding: 7px 10px;
border-radius: 6px;
font-size: 12px;
color: var(--text2);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.1s;
border: none;
background: transparent;
text-align: left;
width: 100%;
}
.ctx-item:hover {
background: var(--surface);
color: var(--indigo2);
}
.ctx-item.danger {
color: #fca5a5;
}
.ctx-item.danger:hover {
background: #1c0505;
color: var(--red);
}
.ctx-divider {
height: 1px;
background: var(--border);
margin: 3px 0;
}
/* ── Pinned session indicator ── */
.pin-icon {
font-size: 10px;
color: var(--yellow);
margin-right: 2px;
}
/* ── Share button ── */
.share-btn {
font-size: 12px;
padding: 5px 12px;
border-radius: 8px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text2);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px;
}
.share-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
/* ── Floating help button ── */
.help-fab {
position: fixed;
bottom: 80px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
font-size: 17px;
font-weight: 700;
border: none;
cursor: pointer;
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.5);
transition:
transform 0.15s,
box-shadow 0.15s;
}
.help-fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.7);
}
/* ── Help guide panel ── */
.help-panel-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 450;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.help-panel-overlay.open {
display: flex;
}
.help-panel {
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 18px;
padding: 24px 28px 28px;
width: 100%;
max-width: 660px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
animation: popIn 0.2s ease;
}
@keyframes popIn {
from {
transform: scale(0.96);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.help-panel-title {
font-size: 15px;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.help-section {
margin-bottom: 14px;
}
.help-section-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 7px;
}
.help-feature-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
@media (max-width: 500px) {
.help-feature-grid {
grid-template-columns: 1fr 1fr;
}
}
.help-feature {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9px;
padding: 9px 11px;
cursor: pointer;
transition: all 0.12s;
}
.help-feature:hover {
border-color: var(--indigo);
background: var(--indigo-bg);
}
.help-feature-icon {
font-size: 16px;
margin-bottom: 4px;
}
.help-feature-name {
font-size: 12px;
font-weight: 600;
color: var(--text2);
}
.help-feature-desc {
font-size: 10px;
color: var(--text3);
margin-top: 2px;
line-height: 1.4;
}
/* ── Export as image card ── */
#export-canvas {
display: block;
width: 100%;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.export-card-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.export-action-btn {
flex: 1;
padding: 9px;
border-radius: 9px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text2);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.export-action-btn.primary {
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
border-color: transparent;
color: #fff;
}
.export-action-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
}
.export-action-btn.primary:hover {
opacity: 0.9;
}
/* ── Portfolio heat map ── */
.heatmap-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px 0;
min-height: 120px;
}
.hm-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 8px;
cursor: pointer;
transition:
transform 0.12s,
box-shadow 0.12s;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
user-select: none;
}
.hm-tile:hover {
transform: scale(1.04);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.heatmap-legend {
display: flex;
gap: 8px;
align-items: center;
font-size: 10px;
color: var(--text3);
flex-wrap: wrap;
}
.hm-legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
/* ── Request inspector panel ── */
.inspector-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
background: var(--surface2);
border-left: 1px solid var(--border2);
z-index: 300;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.4);
}
.inspector-panel.open {
transform: translateX(0);
}
.inspector-header {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.inspector-header-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.inspector-close {
background: none;
border: none;
color: var(--text3);
cursor: pointer;
font-size: 14px;
}
.inspector-body {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.inspector-entry {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9px;
padding: 10px 12px;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 5px;
}
.inspector-entry.latest {
border-color: var(--indigo);
}
.inspector-ts {
font-size: 10px;
color: var(--text3);
}
.inspector-query {
font-size: 12px;
color: var(--text);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspector-row {
display: flex;
gap: 8px;
align-items: baseline;
}
.insp-key {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text3);
width: 68px;
flex-shrink: 0;
}
.insp-val {
font-size: 11px;
color: var(--text2);
}
.insp-conf-high {
color: var(--green);
}
.insp-conf-med {
color: var(--yellow);
}
.insp-conf-low {
color: var(--red);
}
.insp-bar-row {
display: flex;
align-items: center;
gap: 6px;
}
.insp-bar-track {
flex: 1;
height: 4px;
background: var(--border2);
border-radius: 2px;
overflow: hidden;
}
.insp-bar-fill {
height: 100%;
border-radius: 2px;
background: var(--indigo);
}
.inspector-empty {
color: var(--text3);
font-size: 12px;
padding: 24px 16px;
text-align: center;
line-height: 1.6;
}
/* ── Query history dropdown ── */
.query-history {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
right: 0;
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: var(--radius);
overflow: hidden;
max-height: 220px;
overflow-y: auto;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
z-index: 50;
display: none;
flex-direction: column;
}
.query-history.open {
display: flex;
}
.qh-header {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text3);
padding: 8px 12px 4px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
}
.qh-clear {
font-size: 10px;
color: var(--text3);
border: none;
background: none;
cursor: pointer;
}
.qh-clear:hover {
color: var(--red);
}
.qh-item {
padding: 8px 12px;
font-size: 12px;
color: var(--text2);
cursor: pointer;
border: none;
background: transparent;
width: 100%;
text-align: left;
transition: background 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.qh-item::before {
content: '↺';
color: var(--text3);
font-size: 11px;
flex-shrink: 0;
}
.qh-item:hover,
.qh-item.selected {
background: var(--surface);
color: var(--indigo2);
}
/* ── Custom shortcut card ── */
.add-shortcut-row {
display: flex;
justify-content: center;
margin-top: 16px;
}
.add-shortcut-btn {
font-size: 12px;
padding: 7px 16px;
border-radius: 999px;
border: 1px dashed var(--border2);
background: transparent;
color: var(--text3);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.add-shortcut-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
.custom-card-section {
margin-top: 8px;
}
.custom-card-section .quick-cat-label {
color: var(--yellow);
}
.custom-qb {
position: relative;
}
.custom-qb .qb-delete {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(239, 68, 68, 0.15);
color: var(--red);
font-size: 10px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
}
.custom-qb:hover .qb-delete {
display: flex;
}
/* ── Annotation note ── */
.annotation-wrap {
margin-top: 6px;
display: none;
max-width: 560px;
}
.annotation-wrap.open {
display: block;
}
.annotation-textarea {
width: 100%;
min-height: 52px;
background: #1e1a05;
border: 1px solid rgba(217, 119, 6, 0.25);
border-radius: 8px;
color: #fde68a;
padding: 8px 10px;
font-size: 12px;
font-family: inherit;
resize: vertical;
outline: none;
transition: border-color 0.15s;
}
.annotation-textarea:focus {
border-color: #d97706;
}
.annotation-textarea::placeholder {
color: #78350f;
}
.annotation-btn {
position: absolute;
top: 8px;
right: 92px;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
}
.message.agent .bubble:hover .annotation-btn {
opacity: 1;
}
.annotation-btn:hover {
color: #f59e0b;
border-color: #d97706;
}
.annotation-btn.has-note {
opacity: 1;
color: #f59e0b;
border-color: #d97706;
}
/* ── Net worth sparkline ── */
.nw-sparkline {
width: 40px;
height: 18px;
display: inline-block;
vertical-align: middle;
margin-left: 4px;
cursor: pointer;
overflow: visible;
}
.nw-sparkline polyline {
fill: none;
stroke: var(--green);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ── Watchlist alert banner ── */
.watchlist-banner {
background: #0c1a2e;
border-bottom: 1px solid #1e3a5f;
padding: 7px 16px;
font-size: 12px;
color: #93c5fd;
display: none;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.watchlist-banner.show {
display: flex;
}
.watchlist-banner strong {
color: #bfdbfe;
}
.wl-tag {
background: #1e3a5f;
border: 1px solid #3b82f6;
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #93c5fd;
cursor: pointer;
transition: background 0.1s;
}
.wl-tag:hover {
background: #2563eb;
color: #fff;
}
.wl-add-btn {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
border: 1px dashed #3b82f6;
background: transparent;
color: #60a5fa;
cursor: pointer;
}
.wl-add-btn:hover {
background: #1e3a5f;
}
.wl-dismiss {
margin-left: auto;
background: none;
border: none;
color: #60a5fa;
cursor: pointer;
font-size: 12px;
}
/* ── Pinned messages strip ── */
.pinned-strip {
background: #0d1a2e;
border-bottom: 1px solid #1e3a5f;
padding: 8px 16px;
display: none;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.pinned-strip.has-pins {
display: flex;
}
.pinned-strip-header {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #60a5fa;
margin-bottom: 2px;
display: flex;
align-items: center;
justify-content: space-between;
}
.pinned-strip-header button {
font-size: 10px;
color: #60a5fa;
border: none;
background: none;
cursor: pointer;
font-weight: 400;
}
.pinned-msg-item {
font-size: 12px;
color: #93c5fd;
cursor: pointer;
padding: 3px 0;
border-bottom: 1px solid rgba(30, 58, 95, 0.5);
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
}
.pinned-msg-item:last-child {
border-bottom: none;
}
.pinned-msg-item:hover {
color: #bfdbfe;
}
.pinned-msg-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pinned-msg-remove {
background: none;
border: none;
color: #3b82f6;
cursor: pointer;
font-size: 11px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.pinned-msg-item:hover .pinned-msg-remove {
opacity: 1;
}
/* ── Pin bubble button ── */
.pin-bubble-btn {
position: absolute;
top: 8px;
right: 120px;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
}
.message.agent .bubble:hover .pin-bubble-btn {
opacity: 1;
}
.pin-bubble-btn:hover {
color: #60a5fa;
border-color: #3b82f6;
}
.pin-bubble-btn.pinned {
opacity: 1;
color: #60a5fa;
border-color: #3b82f6;
}
/* ── Message reactions ── */
.reaction-row {
display: flex;
gap: 4px;
margin-top: 6px;
padding-left: 2px;
}
.reaction-btn {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
cursor: pointer;
color: var(--text3);
transition: all 0.15s;
}
.reaction-btn:hover {
border-color: var(--border2);
color: var(--text2);
}
.reaction-btn.up.active {
border-color: var(--green);
color: var(--green);
background: #052e16;
}
.reaction-btn.down.active {
border-color: var(--red);
color: var(--red);
background: #1c0505;
}
/* ── Memory indicator in header ── */
.memory-indicator {
font-size: 10px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid rgba(99, 102, 241, 0.35);
background: rgba(99, 102, 241, 0.08);
color: var(--indigo2);
cursor: pointer;
transition: all 0.15s;
display: none;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.memory-indicator.has-memory {
display: inline-flex;
}
.memory-indicator:hover {
background: rgba(99, 102, 241, 0.2);
}
/* ── Memory panel ── */
.memory-panel {
position: fixed;
top: 60px;
right: 20px;
background: var(--surface2);
border: 1px solid var(--indigo);
border-radius: var(--radius);
padding: 14px 16px;
min-width: 240px;
max-width: 320px;
z-index: 200;
display: none;
flex-direction: column;
gap: 8px;
box-shadow: 0 8px 28px rgba(99, 102, 241, 0.25);
animation: popIn 0.15s ease;
}
.memory-panel.open {
display: flex;
}
.memory-panel-title {
font-size: 12px;
font-weight: 700;
color: var(--indigo2);
display: flex;
align-items: center;
justify-content: space-between;
}
.memory-section-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 4px;
}
.memory-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text2);
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 6px;
padding: 2px 7px;
margin: 2px;
cursor: pointer;
}
.memory-tag .mem-del {
color: var(--text3);
font-size: 10px;
}
.memory-tag:hover .mem-del {
color: var(--red);
}
.memory-forget-btn {
font-size: 11px;
padding: 4px 10px;
border-radius: 7px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: transparent;
color: #fca5a5;
cursor: pointer;
margin-top: 4px;
}
.memory-forget-btn:hover {
background: #1c0505;
}
/* ── Scroll-to-bottom button ── */
.scroll-btn {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 999px;
padding: 6px 14px;
font-size: 12px;
color: var(--text2);
cursor: pointer;
z-index: 200;
display: none;
align-items: center;
gap: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
transition: all 0.15s;
}
.scroll-btn.visible {
display: flex;
}
.scroll-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
background: var(--indigo-bg);
}
/* ── High contrast theme ── */
[data-theme='contrast'] {
--bg: #000000;
--surface: #0a0a0a;
--surface2: #111111;
--border: #404040;
--border2: #606060;
--indigo: #7c7cff;
--indigo2: #a0a0ff;
--indigo-bg: #111133;
--green: #00ff88;
--yellow: #ffff00;
--red: #ff4444;
--text: #ffffff;
--text2: #dddddd;
--text3: #aaaaaa;
}
/* ── Focus ring (accessibility) ── */
:focus-visible {
outline: 2px solid var(--indigo);
outline-offset: 2px;
border-radius: 4px;
}
button:focus:not(:focus-visible),
input:focus:not(:focus-visible) {
outline: none;
}
/* ── Offline banner ── */
.offline-banner {
background: #7c2d12;
border-bottom: 1px solid #c2410c;
padding: 6px 16px;
font-size: 12px;
color: #fed7aa;
display: none;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.offline-banner.show {
display: flex;
}
.offline-banner strong {
color: #fb923c;
}
.offline-queued {
font-size: 10px;
color: #fdba74;
margin-left: auto;
}
/* ── Copy-as-markdown button ── */
.md-copy-btn {
position: absolute;
top: 8px;
right: 148px;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
}
.message.agent .bubble:hover .md-copy-btn {
opacity: 1;
}
.md-copy-btn:hover {
color: var(--indigo2);
border-color: var(--indigo);
}
/* ── Response disclaimer badge ── */
.disclaimer-badge {
font-size: 10px;
color: var(--text3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 8px;
margin-top: 6px;
display: none;
align-items: center;
gap: 4px;
}
.disclaimer-badge.show {
display: inline-flex;
}
.gf-disclaimer-on .disclaimer-badge {
display: inline-flex;
}
/* ── Command palette ── */
.cmd-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: none;
align-items: flex-start;
justify-content: center;
padding-top: 80px;
}
.cmd-overlay.open {
display: flex;
}
.cmd-box {
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 14px;
width: 100%;
max-width: 560px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
overflow: hidden;
animation: popIn 0.15s ease;
}
.cmd-input-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.cmd-input-icon {
font-size: 16px;
color: var(--text3);
flex-shrink: 0;
}
.cmd-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 14px;
font-family: inherit;
}
.cmd-input::placeholder {
color: var(--text3);
}
.cmd-results {
max-height: 360px;
overflow-y: auto;
}
.cmd-section-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text3);
padding: 8px 16px 4px;
flex-shrink: 0;
}
.cmd-item {
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
transition: background 0.1s;
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.cmd-item.selected,
.cmd-item:hover {
background: var(--indigo-bg);
}
.cmd-item-icon {
font-size: 16px;
flex-shrink: 0;
}
.cmd-item-text {
flex: 1;
}
.cmd-item-label {
font-size: 13px;
color: var(--text);
}
.cmd-item-sub {
font-size: 11px;
color: var(--text3);
margin-top: 1px;
}
.cmd-item-kbd {
font-size: 10px;
color: var(--text3);
flex-shrink: 0;
}
.cmd-footer {
padding: 8px 16px;
border-top: 1px solid var(--border);
display: flex;
gap: 14px;
font-size: 10px;
color: var(--text3);
}
.cmd-footer span {
display: flex;
align-items: center;
gap: 4px;
}
/* ── Cross-session search results ── */
.xsearch-results {
padding: 6px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.xsearch-item {
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.1s;
border: none;
background: transparent;
text-align: left;
width: 100%;
}
.xsearch-item:hover {
background: var(--surface2);
}
.xsearch-session {
font-size: 11px;
font-weight: 700;
color: var(--indigo2);
}
.xsearch-snippet {
font-size: 12px;
color: var(--text2);
margin-top: 2px;
line-height: 1.4;
}
.xsearch-snippet mark {
background: rgba(99, 102, 241, 0.3);
color: var(--indigo2);
border-radius: 2px;
}
/* ── User profile modal ── */
.profile-step {
display: none;
flex-direction: column;
gap: 12px;
}
.profile-step.active {
display: flex;
}
.profile-option {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text2);
cursor: pointer;
text-align: left;
transition: all 0.15s;
font-size: 13px;
display: flex;
align-items: center;
gap: 10px;
}
.profile-option:hover,
.profile-option.selected {
border-color: var(--indigo);
background: var(--indigo-bg);
color: var(--indigo2);
}
.profile-option-icon {
font-size: 18px;
}
.profile-progress {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.profile-dot {
flex: 1;
height: 3px;
border-radius: 2px;
background: var(--border2);
}
.profile-dot.done {
background: var(--indigo);
}
/* ── Rental yield calculator ── */
.calc-row {
display: flex;
gap: 10px;
align-items: center;
}
.calc-input {
flex: 1;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
font-family: inherit;
}
.calc-input:focus {
border-color: var(--indigo);
}
.calc-label {
font-size: 12px;
color: var(--text2);
min-width: 80px;
}
.calc-result {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 10px;
padding: 12px 16px;
margin-top: 8px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.calc-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.calc-stat-val {
font-size: 18px;
font-weight: 700;
color: var(--green);
}
.calc-stat-lbl {
font-size: 10px;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ── Property comparison table ── */
.prop-compare-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 8px;
}
.prop-compare-table th {
background: var(--surface);
color: var(--text2);
padding: 8px 10px;
font-weight: 700;
font-size: 11px;
text-align: left;
border-bottom: 1px solid var(--border2);
}
.prop-compare-table td {
padding: 7px 10px;
border-bottom: 1px solid var(--border);
color: var(--text2);
}
.prop-compare-table tr:last-child td {
border-bottom: none;
}
.prop-compare-table .highlight {
color: var(--green);
font-weight: 700;
}
/* ── Portfolio donut chart ── */
.donut-wrap {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
padding: 4px 0;
}
.donut-svg {
flex-shrink: 0;
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.donut-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text2);
cursor: pointer;
}
.donut-legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.donut-legend-pct {
margin-left: auto;
font-weight: 700;
color: var(--text);
}
/* ── Market calendar strip ── */
.market-calendar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 5px 16px;
display: none;
align-items: center;
gap: 12px;
flex-shrink: 0;
overflow-x: auto;
}
.market-calendar.show {
display: flex;
}
.cal-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--text3);
flex-shrink: 0;
}
.cal-event {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text2);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 8px;
white-space: nowrap;
flex-shrink: 0;
}
.cal-event .cal-date {
color: var(--text3);
font-size: 10px;
}
.cal-event.urgent {
border-color: var(--red);
color: #fca5a5;
}
.cal-event.near {
border-color: var(--yellow);
color: #fde68a;
}
/* ── Smart suggestion banner ── */
.smart-banner {
background: var(--indigo-bg);
border-bottom: 1px solid rgba(99, 102, 241, 0.3);
padding: 6px 16px;
font-size: 12px;
color: var(--indigo2);
display: none;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.smart-banner.show {
display: flex;
}
.smart-banner-action {
background: var(--indigo);
color: #fff;
border: none;
border-radius: 6px;
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
margin-left: 4px;
}
.smart-banner-dismiss {
margin-left: auto;
background: none;
border: none;
color: var(--indigo2);
cursor: pointer;
font-size: 12px;
}
/* ── PWA install prompt ── */
.pwa-banner {
background: var(--surface2);
border-bottom: 1px solid var(--border);
padding: 6px 16px;
font-size: 12px;
color: var(--text2);
display: none;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.pwa-banner.show {
display: flex;
}
.pwa-install-btn {
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
border: none;
border-radius: 6px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.pwa-dismiss {
margin-left: auto;
background: none;
border: none;
color: var(--text3);
cursor: pointer;
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
body.reduced-motion *,
body.reduced-motion *::before,
body.reduced-motion *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* ── Branching indicator ── */
.branch-btn {
font-size: 11px;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--border2);
background: transparent;
color: var(--text3);
cursor: pointer;
transition: all 0.15s;
display: none;
}
.message.user:hover .branch-btn {
display: inline-block;
}
.branch-btn:hover {
border-color: var(--indigo);
color: var(--indigo2);
}
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border2);
border-radius: 3px;
}
</style>
</head>
<body>
<!-- ── Header ── -->
<header>
<div class="logo">📈</div>
<div class="session-title-wrap" id="header-title-wrap">
<h1>Ghostfolio AI Agent</h1>
<span
class="session-subtitle"
id="header-subtitle"
title="Click to rename this chat"
>
<span id="header-subtitle-text">Powered by Claude + LangGraph</span>
<span class="edit-pencil"></span>
</span>
</div>
<button class="chats-btn" id="chats-btn" title="Chat history">
☰ Chats
<span
id="chats-count-badge"
style="
display: none;
background: var(--indigo);
color: #fff;
border-radius: 999px;
padding: 1px 6px;
font-size: 10px;
margin-left: 2px;
"
>0</span
>
</button>
<button class="share-btn" id="share-btn" title="Share conversation">
↑ Share
</button>
<div class="header-right">
<div class="networth-badge" id="networth-badge">
💰 Properties tracked
</div>
<div
class="memory-indicator"
id="memory-indicator"
title="View remembered context"
>
🧠 <span id="memory-label">0 items</span>
</div>
<span class="latency-chip hidden" id="latency-chip"></span>
<div class="user-badge">
<div class="user-avatar" id="user-avatar">??</div>
<span class="user-name" id="user-name">Loading…</span>
</div>
<div class="scenario-badge" id="scenario-badge">🔮 What-if mode</div>
<div class="goal-chip" id="goal-chip" title="Financial goal progress">
🎯 <span id="goal-label">Goal</span>
<div class="goal-bar-track">
<div class="goal-bar-fill" id="goal-bar" style="width: 0%"></div>
</div>
<span id="goal-pct">0%</span>
</div>
<div class="settings-wrap">
<button class="settings-btn" id="settings-btn" title="Settings">
⚙ Settings
</button>
<div class="settings-menu" id="settings-menu">
<button class="settings-item" id="theme-toggle-btn">
<span class="settings-item-left">🌙 Dark / Light mode</span>
<div class="toggle-pill" id="theme-pill"></div>
</button>
<button class="settings-item" id="speak-toggle-settings">
<span class="settings-item-left">🔊 Auto-speak responses</span>
<div class="toggle-pill" id="speak-pill-settings"></div>
</button>
<div class="settings-item" style="cursor: default">
<span class="settings-item-left">🔡 Font size</span>
<div style="display: flex; gap: 4px">
<button
id="fs-small"
onclick="
setFontSize('small');
event.stopPropagation();
"
style="
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text2);
cursor: pointer;
font-size: 10px;
font-weight: 700;
"
title="Small"
>
A
</button>
<button
id="fs-medium"
onclick="
setFontSize('medium');
event.stopPropagation();
"
style="
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text2);
cursor: pointer;
font-size: 12px;
font-weight: 700;
"
title="Medium"
>
A
</button>
<button
id="fs-large"
onclick="
setFontSize('large');
event.stopPropagation();
"
style="
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text2);
cursor: pointer;
font-size: 14px;
font-weight: 700;
"
title="Large"
>
A
</button>
</div>
</div>
<button class="settings-item" id="scenario-toggle-btn">
<span class="settings-item-left">🔮 What-if scenario mode</span>
<div class="toggle-pill" id="scenario-pill"></div>
</button>
<div class="settings-divider"></div>
<button class="settings-item" id="set-goal-btn">
<span class="settings-item-left">🎯 Set financial goal</span>
<span style="font-size: 10px; color: var(--text3)"></span>
</button>
<button class="settings-item" id="export-card-btn">
<span class="settings-item-left">🖼 Export as image card</span>
<span style="font-size: 10px; color: var(--text3)">PNG</span>
</button>
<button class="settings-item" id="heatmap-btn">
<span class="settings-item-left">🟩 Portfolio heat map</span>
<span style="font-size: 10px; color: var(--text3)">Visual</span>
</button>
<button class="settings-item" id="inspector-btn">
<span class="settings-item-left">🔬 Request inspector</span>
<div class="toggle-pill" id="inspector-pill"></div>
</button>
<button class="settings-item" id="tldr-btn">
<span class="settings-item-left">📋 Summarize this chat</span>
<span style="font-size: 10px; color: var(--text3)">TL;DR</span>
</button>
<button class="settings-item" id="print-btn">
<span class="settings-item-left">🖨 Export as PDF</span>
<span style="font-size: 10px; color: var(--text3)">Print</span>
</button>
<div class="settings-divider"></div>
<button class="settings-item" id="contrast-toggle-btn">
<span class="settings-item-left">⬛ High contrast mode</span>
<div class="toggle-pill" id="contrast-pill"></div>
</button>
<button class="settings-item" id="motion-toggle-btn">
<span class="settings-item-left">🎞 Reduced motion</span>
<div class="toggle-pill" id="motion-pill"></div>
</button>
<button class="settings-item" id="disclaimer-toggle-btn">
<span class="settings-item-left"
>⚠ Show disclaimer on responses</span
>
<div class="toggle-pill" id="disclaimer-pill"></div>
</button>
<button class="settings-item" id="calendar-toggle-btn">
<span class="settings-item-left">📅 Market calendar</span>
<div class="toggle-pill" id="calendar-pill"></div>
</button>
<div class="settings-divider"></div>
<button class="settings-item" id="profile-btn">
<span class="settings-item-left">👤 Edit my profile</span>
<span style="font-size: 10px; color: var(--text3)"></span>
</button>
<button class="settings-item" id="reminder-btn">
<span class="settings-item-left">🔔 Set portfolio reminder</span>
<span style="font-size: 10px; color: var(--text3)"></span>
</button>
<button class="settings-item" id="email-digest-btn">
<span class="settings-item-left">📧 Email digest</span>
<span style="font-size: 10px; color: var(--text3)">Copy</span>
</button>
<button class="settings-item" id="batch-export-btn">
<span class="settings-item-left">☑ Batch export</span>
<span style="font-size: 10px; color: var(--text3)">Select</span>
</button>
<div class="settings-divider"></div>
<button class="settings-item" id="shortcuts-btn">
<span class="settings-item-left">⌨ Keyboard shortcuts</span>
<span style="font-size: 10px; color: var(--text3)">⌘?</span>
</button>
<div class="settings-scroll-hint">
scroll for more · ⌘P for all features
</div>
</div>
</div>
<button class="clear-btn" id="clear-btn">Clear session</button>
<button
class="clear-btn"
id="logout-btn"
style="border-color: #2a3550; color: #64748b"
title="Sign out"
>
Sign out
</button>
</div>
</header>
<!-- ── Session history drawer ── -->
<div class="drawer-overlay" id="drawer-overlay"></div>
<div class="sessions-drawer" id="sessions-drawer">
<div class="drawer-header">
<h3>💬 Chats</h3>
<button class="drawer-close" id="drawer-close"></button>
</div>
<div class="drawer-tabs">
<button
class="drawer-tab active"
id="tab-chats"
onclick="switchDrawerTab('chats')"
>
Chats
</button>
<button
class="drawer-tab"
id="tab-saved"
onclick="switchDrawerTab('saved')"
>
⭐ Saved
</button>
<button
class="drawer-tab"
id="tab-archived"
onclick="switchDrawerTab('archived')"
>
🗂
</button>
</div>
<div style="padding: 8px 12px 0">
<input
autocomplete="off"
id="xsearch-input"
placeholder="🔍 Search all chats…"
style="
width: 100%;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 7px 10px;
color: var(--text);
font-size: 12px;
outline: none;
font-family: inherit;
"
/>
</div>
<div
class="xsearch-results"
id="xsearch-results"
style="display: none; padding: 4px 8px"
></div>
<button
class="drawer-new-btn"
id="drawer-new-btn"
style="margin-top: 8px"
>
+ New Chat
</button>
<div class="session-list" id="session-list"></div>
<div class="saved-list" id="saved-list" style="display: none"></div>
<div class="session-list" id="archived-list" style="display: none"></div>
</div>
<!-- ── Pinned context strip ── -->
<div class="context-strip" id="context-strip">
<span class="context-tag" id="ctx-portfolio">📊 Portfolio</span>
<span class="context-tag" id="ctx-realestate">🏠 Real estate</span>
<span class="context-tag" id="ctx-property">🏘 Property tracked</span>
<span class="context-tag" id="ctx-market">💹 Market data</span>
<span class="context-tag" id="ctx-compliance">🛡 Compliance</span>
<span class="context-tag" id="ctx-tax">🧾 Tax</span>
</div>
<!-- ── Conversation search ── -->
<div class="search-bar" id="search-bar">
<input
autocomplete="off"
class="search-input"
id="search-input"
placeholder="Search conversation…"
/>
<span class="search-count" id="search-count"></span>
<div class="search-nav">
<button id="search-prev" title="Previous"></button>
<button id="search-next" title="Next"></button>
</div>
<button class="search-close" id="search-close"></button>
</div>
<!-- ── Offline banner ── -->
<div class="offline-banner" id="offline-banner">
<strong>⚠ No connection</strong> — Your message will be sent when you
reconnect.
<span class="offline-queued" id="offline-queued"></span>
</div>
<!-- ── PWA install banner ── -->
<div class="pwa-banner" id="pwa-banner">
📱 Install Ghostfolio AI as an app for faster access
<button class="pwa-install-btn" id="pwa-install-btn">Install</button>
<button
class="pwa-dismiss"
onclick="
document.getElementById('pwa-banner').classList.remove('show');
localStorage.setItem('gf_pwa_dismissed', '1');
"
>
</button>
</div>
<!-- ── Smart suggestion banner ── -->
<div class="smart-banner" id="smart-banner">
<span id="smart-banner-text"></span>
<button class="smart-banner-action" id="smart-banner-action">
Ask now
</button>
<button
class="smart-banner-dismiss"
onclick="
document.getElementById('smart-banner').classList.remove('show');
localStorage.setItem(
'gf_smart_dismissed_' + new Date().toDateString(),
'1'
);
"
>
</button>
</div>
<!-- ── Market calendar strip ── -->
<div class="market-calendar" id="market-calendar">
<span class="cal-label">📅 Upcoming</span>
<div
id="cal-events"
style="display: flex; gap: 8px; flex-wrap: nowrap"
></div>
<button
onclick="
document.getElementById('market-calendar').classList.remove('show');
localStorage.setItem('gf_cal_hidden', '1');
"
style="
margin-left: auto;
background: none;
border: none;
color: var(--text3);
cursor: pointer;
font-size: 11px;
"
>
Hide
</button>
</div>
<!-- ── Watchlist alert banner ── -->
<div class="watchlist-banner" id="watchlist-banner">
<strong>👁 Watchlist:</strong>
<div
id="watchlist-tags"
style="display: flex; gap: 6px; flex-wrap: wrap"
></div>
<button class="wl-add-btn" onclick="addToWatchlistPrompt()">
+ Add ticker
</button>
<button
class="wl-dismiss"
onclick="
document.getElementById('watchlist-banner').classList.remove('show')
"
title="Hide"
>
</button>
</div>
<!-- ── Proactive greeting banner ── -->
<div class="greeting-banner" id="greeting-banner">
<div class="greeting-banner-text">
<strong id="greeting-text">Welcome back!</strong><br />
<span id="greeting-sub"
>Your portfolio hasn't been reviewed in a while.</span
>
</div>
<button class="greeting-action" id="greeting-action">
Quick summary
</button>
<button class="greeting-dismiss" id="greeting-dismiss" title="Dismiss">
</button>
</div>
<!-- ── Session toast ── -->
<div class="session-toast" id="session-toast"></div>
<!-- ── Pinned messages strip ── -->
<div class="pinned-strip" id="pinned-strip">
<div class="pinned-strip-header">
📌 Pinned
<button onclick="clearAllPinnedMsgs()">Clear all</button>
</div>
<div id="pinned-strip-items"></div>
</div>
<!-- ── Chat area ── -->
<div class="chat-area" id="chat">
<!-- Empty state -->
<div class="empty-state" id="empty">
<div class="empty-icon">💼</div>
<h2>What would you like to know?</h2>
<p>
Ask about your portfolio, track real estate holdings, analyze
investments, or run a compliance check.
</p>
<div class="quick-grid">
<div class="quick-category">
<span class="quick-cat-label">📊 Portfolio</span>
<div class="quick-row">
<button
class="quick-btn"
onclick="sendQuick('What is my YTD return?')"
>
<span class="qb-icon">📈</span>
<span class="qb-title">YTD Return</span>
<span class="qb-sub">Year-to-date performance</span>
</button>
<button
class="quick-btn"
onclick="sendQuick('Give me a full portfolio summary')"
>
<span class="qb-icon">📋</span>
<span class="qb-title">Portfolio Summary</span>
<span class="qb-sub">Allocation &amp; value overview</span>
</button>
</div>
</div>
<div class="quick-category">
<span class="quick-cat-label">🛡️ Risk &amp; Compliance</span>
<div class="quick-row">
<button
class="quick-btn"
onclick="sendQuick('Am I over-concentrated in any stock?')"
>
<span class="qb-icon">⚖️</span>
<span class="qb-title">Concentration Check</span>
<span class="qb-sub">Detect overweight positions</span>
</button>
<button
class="quick-btn"
onclick="sendQuick('Estimate my tax liability')"
>
<span class="qb-icon">🧾</span>
<span class="qb-title">Tax Estimate</span>
<span class="qb-sub">Capital gains &amp; liability</span>
</button>
</div>
</div>
<div class="quick-category">
<span class="quick-cat-label">💹 Market</span>
<div class="quick-row">
<button
class="quick-btn"
onclick="sendQuick('What is AAPL trading at today?')"
>
<span class="qb-icon">🍎</span>
<span class="qb-title">Live Quote</span>
<span class="qb-sub">AAPL real-time price</span>
</button>
<button
class="quick-btn"
onclick="sendQuick('Show me my recent transactions')"
>
<span class="qb-icon">🔄</span>
<span class="qb-title">Recent Trades</span>
<span class="qb-sub">Activity history</span>
</button>
</div>
</div>
<div class="quick-category">
<span class="quick-cat-label">🏘 Real Estate Holdings</span>
<div class="quick-row">
<button
class="quick-btn"
onclick="sendQuick('Show my properties')"
>
<span class="qb-icon">🏘</span>
<span class="qb-title">My Properties</span>
<span class="qb-sub">Equity &amp; portfolio view</span>
</button>
<button
class="quick-btn"
onclick="
sendQuick('What is my total net worth including real estate?')
"
>
<span class="qb-icon">💰</span>
<span class="qb-title">Total Net Worth</span>
<span class="qb-sub">Portfolio + real estate equity</span>
</button>
</div>
<div class="quick-row">
<button
class="quick-btn"
onclick="sendQuick('Add a property to my portfolio')"
>
<span class="qb-icon"></span>
<span class="qb-title">Add a Property</span>
<span class="qb-sub">Track address, value &amp; mortgage</span>
</button>
<button
class="quick-btn"
onclick="sendQuick('What is my real estate equity?')"
>
<span class="qb-icon">📈</span>
<span class="qb-title">Real Estate Equity</span>
<span class="qb-sub">Equity across all properties</span>
</button>
</div>
</div>
<!-- Custom shortcut cards (user-defined) -->
<div
class="quick-category custom-card-section"
id="custom-cards-section"
style="display: none"
>
<span class="quick-cat-label">⭐ My Shortcuts</span>
<div class="quick-row" id="custom-cards-row"></div>
</div>
</div>
<div class="add-shortcut-row">
<button class="add-shortcut-btn" onclick="openAddCardModal()">
+ Add custom shortcut
</button>
</div>
</div>
</div>
<!-- ── Input area ── -->
<div class="input-wrap">
<div class="length-bar">
<span class="length-label">Response:</span>
<div class="length-pills">
<button
class="length-pill"
id="len-brief"
onclick="setLength('brief')"
>
Brief
</button>
<button
class="length-pill active"
id="len-normal"
onclick="setLength('normal')"
>
Normal
</button>
<button
class="length-pill"
id="len-detailed"
onclick="setLength('detailed')"
>
Detailed
</button>
</div>
</div>
<div class="attach-preview" id="attach-preview">
<span id="attach-preview-name">📎 No file</span>
<button
class="attach-preview-remove"
id="attach-remove"
title="Remove file"
>
</button>
</div>
<div class="query-history" id="query-history"></div>
<div class="input-row">
<div class="attach-wrap">
<button class="attach-btn" id="attach-btn" title="Attach file">
</button>
<div class="attach-menu" id="attach-menu">
<button class="attach-item" id="attach-file-btn">
<span class="attach-item-icon">📄</span>
<div>
<div style="font-weight: 600">Upload document</div>
<div style="font-size: 10px; color: var(--text3)">
.txt, .csv, .json
</div>
</div>
</button>
<button class="attach-item" id="attach-photo-btn">
<span class="attach-item-icon">📷</span>
<div>
<div style="font-weight: 600">Take photo / upload image</div>
<div style="font-size: 10px; color: var(--text3)">
Mobile camera or image file
</div>
</div>
</button>
</div>
<input
accept=".txt,.csv,.json"
id="attach-file-input"
style="display: none"
type="file"
/>
<input
accept="image/*"
capture="environment"
id="attach-photo-input"
style="display: none"
type="file"
/>
</div>
<textarea
id="input"
placeholder="Ask anything about your portfolio… (type /tools to see available tools)"
rows="1"
></textarea>
<button class="mic-btn" id="mic-btn" title="Voice input">🎙</button>
<button class="send-btn" id="send-btn" onclick="send()"></button>
</div>
</div>
<!-- ── Scroll-to-bottom button ── -->
<button
class="scroll-btn"
id="scroll-btn"
onclick="chat.scrollTop = chat.scrollHeight"
>
↓ Latest
</button>
<!-- ── Floating help button ── -->
<button class="help-fab" id="help-fab" title="What can this agent do?">
?
</button>
<!-- ── Help guide panel ── -->
<div class="help-panel-overlay" id="help-overlay">
<div class="help-panel">
<div class="help-panel-title">
What can this agent do?
<button class="modal-close-btn" onclick="closeHelp()"></button>
</div>
<div class="help-section">
<div class="help-section-title">💬 Ask anything — try these</div>
<div class="help-feature-grid">
<div
class="help-feature"
onclick="closeHelpAndSend('Give me a full portfolio summary')"
>
<div class="help-feature-icon">📊</div>
<div class="help-feature-name">Portfolio Summary</div>
<div class="help-feature-desc">Value, holdings, YTD return</div>
</div>
<div
class="help-feature"
onclick="closeHelpAndSend('What is my real estate equity?')"
>
<div class="help-feature-icon">🏘</div>
<div class="help-feature-name">Real Estate Equity</div>
<div class="help-feature-desc">Equity across all tracked properties</div>
</div>
<div
class="help-feature"
onclick="
closeHelpAndSend(
'What is my total net worth including real estate?'
)
"
>
<div class="help-feature-icon">💰</div>
<div class="help-feature-name">Total Net Worth</div>
<div class="help-feature-desc">Portfolio + property equity</div>
</div>
<div
class="help-feature"
onclick="closeHelpAndSend('Am I over-concentrated in any stock?')"
>
<div class="help-feature-icon">⚖️</div>
<div class="help-feature-name">Risk Check</div>
<div class="help-feature-desc">
Concentration &amp; compliance
</div>
</div>
<div
class="help-feature"
onclick="closeHelpAndSend('Estimate my tax liability')"
>
<div class="help-feature-icon">🧾</div>
<div class="help-feature-name">Tax Estimate</div>
<div class="help-feature-desc">Capital gains liability</div>
</div>
<div
class="help-feature"
onclick="closeHelpAndSend('Compare Round Rock vs Hays County investment returns and rental yield')"
>
<div class="help-feature-icon">🔀</div>
<div class="help-feature-name">Compare Markets</div>
<div class="help-feature-desc">Investment returns &amp; rental yield</div>
</div>
</div>
</div>
<div class="help-section">
<div class="help-section-title">⚡ Power features</div>
<div class="help-feature-grid">
<div
class="help-feature"
onclick="
closeHelp();
input.value = '~';
input.dispatchEvent(new KeyboardEvent('keyup'));
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Input Templates</div>
<div class="help-feature-desc">
Type ~ to pick a pre-built query
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openShortcutModal();
"
>
<div class="help-feature-icon">🎹</div>
<div class="help-feature-name">Keyboard Shortcuts</div>
<div class="help-feature-desc">
⌘K focus · ↑ restore · ⌘F search
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
if (recognition) startListening();
"
>
<div class="help-feature-icon">🎙</div>
<div class="help-feature-name">Voice Input</div>
<div class="help-feature-desc">Click 🎙 or tap mic button</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
applyScenarioMode(true);
"
>
<div class="help-feature-icon">🔮</div>
<div class="help-feature-name">What-if Mode</div>
<div class="help-feature-desc">
Hypothetical financial scenarios
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openGoalModal();
"
>
<div class="help-feature-icon">🎯</div>
<div class="help-feature-name">Set a Goal</div>
<div class="help-feature-desc">Track progress to your target</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
document.getElementById('share-btn').click();
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Share Chat</div>
<div class="help-feature-desc">
Copy link or formatted summary
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
document.getElementById('memory-indicator').click();
"
>
<div class="help-feature-icon">🧠</div>
<div class="help-feature-name">Context Memory</div>
<div class="help-feature-desc">
Agent remembers tickers &amp; net worth across sessions
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
addToWatchlistPrompt();
"
>
<div class="help-feature-icon">👁</div>
<div class="help-feature-name">Watchlist</div>
<div class="help-feature-desc">
Track tickers — type /watch AAPL
</div>
</div>
</div>
</div>
<div class="help-section">
<div class="help-section-title">✍ Annotate &amp; React</div>
<div class="help-feature-grid">
<div class="help-feature">
<div class="help-feature-icon">📝</div>
<div class="help-feature-name">Sticky Notes</div>
<div class="help-feature-desc">
Click 📝 on any response to add a private note
</div>
</div>
<div class="help-feature">
<div class="help-feature-icon">📌</div>
<div class="help-feature-name">Pin Responses</div>
<div class="help-feature-desc">
Click 📌 to pin key answers to the top bar
</div>
</div>
<div class="help-feature">
<div class="help-feature-icon">👍</div>
<div class="help-feature-name">Rate Responses</div>
<div class="help-feature-desc">
👍 👎 buttons below each answer for feedback
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openAddCardModal();
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Custom Shortcuts</div>
<div class="help-feature-desc">
Save your own quick-action cards to the landing page
</div>
</div>
<div class="help-feature">
<div class="help-feature-icon"></div>
<div class="help-feature-name">Query History</div>
<div class="help-feature-desc">
Click empty input to see &amp; reuse past queries
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
setFontSize('large');
"
>
<div class="help-feature-icon">🔡</div>
<div class="help-feature-name">Font Size</div>
<div class="help-feature-desc">A–A–A controls in ⚙ Settings</div>
</div>
</div>
</div>
<div class="help-section">
<div class="help-section-title">🗂 Manage your chats</div>
<div class="help-feature-grid">
<div
class="help-feature"
onclick="
closeHelp();
openDrawer();
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Chat History</div>
<div class="help-feature-desc">
Up to 15 sessions saved locally
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openDrawer();
switchDrawerTab('saved');
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Saved Responses</div>
<div class="help-feature-desc">Star ★ any reply to save it</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
downloadConversation();
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Export Conversation</div>
<div class="help-feature-desc">Download as .txt file</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
window.print();
"
>
<div class="help-feature-icon">🖨</div>
<div class="help-feature-name">Print / Save PDF</div>
<div class="help-feature-desc">Clean print-ready layout</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openBatchExport();
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Batch Export</div>
<div class="help-feature-desc">
Select specific responses to export
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
copyEmailDigest();
"
>
<div class="help-feature-icon">📧</div>
<div class="help-feature-name">Email Digest</div>
<div class="help-feature-desc">Copy formatted chat for email</div>
</div>
</div>
</div>
<div class="help-section">
<div class="help-section-title">🧮 Financial Tools</div>
<div class="help-feature-grid">
<div
class="help-feature"
onclick="
closeHelp();
openYieldCalc();
"
>
<div class="help-feature-icon">🏠</div>
<div class="help-feature-name">Rental Yield Calc</div>
<div class="help-feature-desc">
Gross/net yield &amp; cap rate
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openDonutChart();
"
>
<div class="help-feature-icon">🍩</div>
<div class="help-feature-name">Donut Chart</div>
<div class="help-feature-desc">
Portfolio allocation breakdown
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openPropCompare();
"
>
<div class="help-feature-icon">🏘</div>
<div class="help-feature-name">Compare Properties</div>
<div class="help-feature-desc">
Side-by-side property analysis
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
openPalette();
"
>
<div class="help-feature-icon"></div>
<div class="help-feature-name">Command Palette</div>
<div class="help-feature-desc">All features instantly (⌘P)</div>
</div>
<div
class="help-feature"
onclick="closeHelp(); openProfile();"
>
<div class="help-feature-icon">👤</div>
<div class="help-feature-name">My Profile</div>
<div class="help-feature-desc">
Risk, focus, horizon — personalizes agent
</div>
</div>
<div
class="help-feature"
onclick="
closeHelp();
document.getElementById('reminder-modal').classList.add('open');
"
>
<div class="help-feature-icon">🔔</div>
<div class="help-feature-name">Set Reminder</div>
<div class="help-feature-desc">
Browser notification to check portfolio
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Input template picker (shown above textarea when ~ typed) ── -->
<div class="template-picker" id="template-picker">
<div class="template-picker-header">⌨ Templates — select to fill</div>
<div
class="template-item"
onclick="
useTemplate(
'Add my property at [address] in Austin TX, purchased for $[price], current value $[value], mortgage balance $[mortgage]'
)
"
>
<strong>🏠 Track a property</strong>
<div class="tmpl-hint">
Fill in address, purchase price, current value, mortgage
</div>
</div>
<div
class="template-item"
onclick="
useTemplate(
'What would happen to my net worth if Austin home prices drop [X]%?'
)
"
>
<strong>🔮 What-if scenario</strong>
<div class="tmpl-hint">Hypothetical price change analysis</div>
</div>
<div
class="template-item"
onclick="
useTemplate('Compare [County A] vs [County B] real estate market')
"
>
<strong>🔀 Compare counties</strong>
<div class="tmpl-hint">Side-by-side market comparison</div>
</div>
<div
class="template-item"
onclick="
useTemplate(
'Am I on track to retire at age [age] with $[target] saved?'
)
"
>
<strong>📅 Retirement check</strong>
<div class="tmpl-hint">Goal-based portfolio analysis</div>
</div>
<div
class="template-item"
onclick="
useTemplate('What is the tax impact if I sell [ticker] today?')
"
>
<strong>🧾 Tax impact</strong>
<div class="tmpl-hint">Capital gains on a specific holding</div>
</div>
<div
class="template-item"
onclick="
useTemplate(
'Show me the rental yield comparison across all Austin-area counties'
)
"
>
<strong>🏘 Rental yield scan</strong>
<div class="tmpl-hint">Compare rent-to-price ratios</div>
</div>
<div
class="template-item"
onclick="
useTemplate(
'What is [TICKER] trading at today and what is the analyst consensus?'
)
"
>
<strong>👁 Watchlist check</strong>
<div class="tmpl-hint">Price + sentiment for any ticker</div>
</div>
<div
class="template-item"
onclick="
useTemplate(
'Summarize my portfolio performance over the last [30/90/365] days'
)
"
>
<strong>📅 Period summary</strong>
<div class="tmpl-hint">Performance over a custom time range</div>
</div>
</div>
<!-- ── Keyboard shortcut modal ── -->
<div class="modal-overlay" id="shortcut-modal">
<div class="modal-box">
<div class="modal-title">
⌨ Keyboard Shortcuts
<button class="modal-close-btn" onclick="closeShortcutModal()">
</button>
</div>
<table class="shortcut-table">
<tr>
<td>Send message</td>
<td><span class="kbd">Enter</span></td>
</tr>
<tr>
<td>New line</td>
<td>
<span class="kbd">Shift</span> <span class="kbd">Enter</span>
</td>
</tr>
<tr>
<td>Restore last message</td>
<td><span class="kbd"></span> (when input empty)</td>
</tr>
<tr>
<td>Focus input</td>
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">K</span></td>
</tr>
<tr>
<td>Search conversation</td>
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">F</span></td>
</tr>
<tr>
<td>Show tools</td>
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">/</span></td>
</tr>
<tr>
<td>Export conversation</td>
<td>
<span class="kbd">⌘/Ctrl</span> <span class="kbd">Shift</span>
<span class="kbd">E</span>
</td>
</tr>
<tr>
<td>Input templates</td>
<td>Type <span class="kbd">~</span> at start of message</td>
</tr>
<tr>
<td>Stop mic / clear input</td>
<td><span class="kbd">Esc</span></td>
</tr>
<tr>
<td>Keyboard shortcuts</td>
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">?</span></td>
</tr>
<tr>
<td>Command palette</td>
<td><span class="kbd">⌘/Ctrl</span> <span class="kbd">P</span></td>
</tr>
<tr>
<td>Add ticker to watchlist</td>
<td>Type <span class="kbd">/watch AAPL</span></td>
</tr>
<tr>
<td>Browse query history</td>
<td><span class="kbd">↑ / ↓</span> when history open</td>
</tr>
</table>
</div>
</div>
<!-- ── Export as Image Card modal ── -->
<div class="modal-overlay" id="export-card-modal">
<div class="modal-box" style="max-width: 560px">
<div class="modal-title">
🖼 Export as Image Card
<button
class="modal-close-btn"
onclick="
document
.getElementById('export-card-modal')
.classList.remove('open')
"
>
</button>
</div>
<canvas id="export-canvas"></canvas>
<div class="export-card-actions">
<button class="export-action-btn primary" onclick="downloadCard()">
⬇ Download PNG
</button>
<button class="export-action-btn" onclick="copyCardToClipboard()">
⎘ Copy image
</button>
</div>
<div
style="
font-size: 11px;
color: var(--text3);
margin-top: 8px;
text-align: center;
"
>
Captures the last agent response as a shareable card
</div>
</div>
</div>
<!-- ── Portfolio heat map modal ── -->
<div class="modal-overlay" id="heatmap-modal">
<div class="modal-box" style="max-width: 720px">
<div class="modal-title">
🟩 Portfolio Heat Map
<button
class="modal-close-btn"
onclick="
document.getElementById('heatmap-modal').classList.remove('open')
"
>
</button>
</div>
<div id="heatmap-content" style="min-height: 140px"></div>
</div>
</div>
<!-- ── Request inspector panel ── -->
<div class="inspector-panel" id="inspector-panel">
<div class="inspector-header">
<div class="inspector-header-title">🔬 Request Inspector</div>
<button
class="inspector-close"
onclick="toggleInspector()"
title="Close"
>
</button>
</div>
<div class="inspector-body" id="inspector-body">
<div class="inspector-empty">
No requests yet.<br />Send a message to start inspecting tool calls,
latency, and confidence scores.
</div>
</div>
</div>
<!-- ── Command palette ── -->
<div
class="cmd-overlay"
id="cmd-overlay"
onclick="if (event.target === this) closePalette();"
>
<div class="cmd-box">
<div class="cmd-input-wrap">
<span class="cmd-input-icon"></span>
<input
autocomplete="off"
class="cmd-input"
id="cmd-input"
placeholder="Type a command or search…"
/>
</div>
<div class="cmd-results" id="cmd-results"></div>
<div class="cmd-footer">
<span
><span
style="
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 1px 5px;
font-size: 9px;
"
>↑↓</span
>
navigate</span
>
<span
><span
style="
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 1px 5px;
font-size: 9px;
"
>Enter</span
>
select</span
>
<span
><span
style="
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 1px 5px;
font-size: 9px;
"
>Esc</span
>
close</span
>
</div>
</div>
</div>
<!-- ── User profile modal ── -->
<div class="modal-overlay" id="profile-modal">
<div class="modal-box" style="max-width: 420px">
<div class="modal-title">
👤 Your Investor Profile
<button
class="modal-close-btn"
onclick="document.getElementById('profile-modal').classList.remove('open')"
></button>
</div>
<div class="profile-progress" id="profile-progress"></div>
<div class="profile-step active" id="profile-step-0">
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px">
What best describes your risk tolerance?
</div>
<div class="profile-option" onclick="selectProfile('risk','conservative',this)">
<span class="profile-option-icon">🛡</span>
<div><div style="font-weight:600">Conservative</div><div style="font-size:11px;color:var(--text3)">Capital preservation first</div></div>
</div>
<div class="profile-option" onclick="selectProfile('risk','moderate',this)">
<span class="profile-option-icon">⚖️</span>
<div><div style="font-weight:600">Moderate</div><div style="font-size:11px;color:var(--text3)">Balanced growth and stability</div></div>
</div>
<div class="profile-option" onclick="selectProfile('risk','aggressive',this)">
<span class="profile-option-icon">🚀</span>
<div><div style="font-weight:600">Aggressive</div><div style="font-size:11px;color:var(--text3)">Maximum growth, higher volatility</div></div>
</div>
<button onclick="nextProfileStep()" style="padding:9px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-top:4px">
Next →
</button>
</div>
<div class="profile-step" id="profile-step-1">
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px">
Primary investment focus?
</div>
<div class="profile-option" onclick="selectProfile('focus','real_estate',this)">
<span class="profile-option-icon">🏠</span>
<div><div style="font-weight:600">Real Estate</div><div style="font-size:11px;color:var(--text3)">Properties, REITs, land</div></div>
</div>
<div class="profile-option" onclick="selectProfile('focus','equities',this)">
<span class="profile-option-icon">📈</span>
<div><div style="font-weight:600">Equities</div><div style="font-size:11px;color:var(--text3)">Stocks, ETFs, growth</div></div>
</div>
<div class="profile-option" onclick="selectProfile('focus','mixed',this)">
<span class="profile-option-icon">🌐</span>
<div><div style="font-weight:600">Diversified</div><div style="font-size:11px;color:var(--text3)">Mix of asset classes</div></div>
</div>
<button onclick="nextProfileStep()" style="padding:9px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-top:4px">
Next →
</button>
</div>
<div class="profile-step" id="profile-step-2">
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:4px">
Investment horizon?
</div>
<div class="profile-option" onclick="selectProfile('horizon','short',this)">
<span class="profile-option-icon"></span>
<div><div style="font-weight:600">Short-term (&lt;2 years)</div></div>
</div>
<div class="profile-option" onclick="selectProfile('horizon','medium',this)">
<span class="profile-option-icon">📅</span>
<div><div style="font-weight:600">Medium-term (2–10 years)</div></div>
</div>
<div class="profile-option" onclick="selectProfile('horizon','long',this)">
<span class="profile-option-icon">🌱</span>
<div><div style="font-weight:600">Long-term (10+ years / retirement)</div></div>
</div>
<button onclick="saveProfile()" style="padding:9px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-top:4px">
Save Profile ✓
</button>
</div>
</div>
</div>
<!-- ── Rental yield calculator ── -->
<div class="modal-overlay" id="yield-modal">
<div class="modal-box" style="max-width: 400px">
<div class="modal-title">
🏠 Rental Yield Calculator
<button
class="modal-close-btn"
onclick="
document.getElementById('yield-modal').classList.remove('open')
"
>
</button>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div class="calc-row">
<span class="calc-label">Property value</span
><input
class="calc-input"
id="yc-value"
oninput="calcYield()"
placeholder="450000"
type="number"
/>
</div>
<div class="calc-row">
<span class="calc-label">Monthly rent</span
><input
class="calc-input"
id="yc-rent"
oninput="calcYield()"
placeholder="2200"
type="number"
/>
</div>
<div class="calc-row">
<span class="calc-label">Annual expenses</span
><input
class="calc-input"
id="yc-expenses"
oninput="calcYield()"
placeholder="5000"
type="number"
/>
</div>
<div class="calc-result" id="yield-result" style="display: none">
<div class="calc-stat">
<div class="calc-stat-val" id="yc-gross"></div>
<div class="calc-stat-lbl">Gross Yield</div>
</div>
<div class="calc-stat">
<div class="calc-stat-val" id="yc-net"></div>
<div class="calc-stat-lbl">Net Yield</div>
</div>
<div class="calc-stat">
<div class="calc-stat-val" id="yc-annual"></div>
<div class="calc-stat-lbl">Annual Income</div>
</div>
<div class="calc-stat">
<div class="calc-stat-val" id="yc-cap"></div>
<div class="calc-stat-lbl">Cap Rate</div>
</div>
</div>
<button
onclick="
sendQuick(
'Based on these numbers, is this a good rental investment in Austin?'
);
document.getElementById('yield-modal').classList.remove('open');
"
style="
padding: 9px;
border-radius: 9px;
border: none;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
"
>
Ask agent to evaluate →
</button>
</div>
</div>
</div>
<!-- ── Property comparison modal ── -->
<div class="modal-overlay" id="prop-compare-modal">
<div class="modal-box" style="max-width: 680px">
<div class="modal-title">
🏘 Compare Properties
<button
class="modal-close-btn"
onclick="
document
.getElementById('prop-compare-modal')
.classList.remove('open')
"
>
</button>
</div>
<div id="prop-compare-content" style="min-height: 80px"></div>
</div>
</div>
<!-- ── Portfolio donut chart modal ── -->
<div class="modal-overlay" id="donut-modal">
<div class="modal-box" style="max-width: 520px">
<div class="modal-title">
🍩 Portfolio Allocation
<button
class="modal-close-btn"
onclick="
document.getElementById('donut-modal').classList.remove('open')
"
>
</button>
</div>
<div id="donut-content" style="min-height: 100px"></div>
</div>
</div>
<!-- ── Batch export modal ── -->
<div class="modal-overlay" id="batch-modal">
<div class="modal-box" style="max-width: 480px">
<div class="modal-title">
☑ Batch Export
<button
class="modal-close-btn"
onclick="
document.getElementById('batch-modal').classList.remove('open')
"
>
</button>
</div>
<div style="font-size: 12px; color: var(--text2); margin-bottom: 12px">
Select responses to include in export:
</div>
<div
id="batch-list"
style="
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
"
></div>
<div style="display: flex; gap: 8px; margin-top: 14px">
<button
onclick="batchExportSelected('txt')"
style="
flex: 1;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--border2);
background: var(--surface);
color: var(--text2);
cursor: pointer;
font-size: 12px;
"
>
⬇ Export .txt
</button>
<button
onclick="batchExportSelected('md')"
style="
flex: 1;
padding: 8px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
cursor: pointer;
font-size: 12px;
font-weight: 600;
"
>
⬇ Export .md
</button>
</div>
</div>
</div>
<!-- ── Reminder modal ── -->
<div class="modal-overlay" id="reminder-modal">
<div class="modal-box" style="max-width: 360px">
<div class="modal-title">
🔔 Portfolio Reminder
<button
class="modal-close-btn"
onclick="
document.getElementById('reminder-modal').classList.remove('open')
"
>
</button>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<div style="font-size: 12px; color: var(--text2)">
Remind me to check my portfolio in:
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap">
<button
class="profile-option"
onclick="setReminder(1)"
style="flex: 1; justify-content: center; padding: 10px"
>
1 day
</button>
<button
class="profile-option"
onclick="setReminder(3)"
style="flex: 1; justify-content: center; padding: 10px"
>
3 days
</button>
<button
class="profile-option"
onclick="setReminder(7)"
style="flex: 1; justify-content: center; padding: 10px"
>
1 week
</button>
<button
class="profile-option"
onclick="setReminder(30)"
style="flex: 1; justify-content: center; padding: 10px"
>
1 month
</button>
</div>
<div style="font-size: 11px; color: var(--text3)">
Uses browser notifications — you'll be prompted to allow.
</div>
</div>
</div>
</div>
<!-- ── Context memory panel ── -->
<div class="memory-panel" id="memory-panel">
<div class="memory-panel-title">
🧠 Remembered Context
<button
onclick="
document.getElementById('memory-panel').classList.remove('open')
"
style="
background: none;
border: none;
color: var(--text3);
cursor: pointer;
font-size: 13px;
"
>
</button>
</div>
<div>
<div class="memory-section-title">Tickers</div>
<div
id="mem-tickers"
style="flex-wrap: wrap; display: flex; gap: 4px"
></div>
</div>
<div>
<div class="memory-section-title">Properties</div>
<div
id="mem-properties"
style="flex-wrap: wrap; display: flex; gap: 4px"
></div>
</div>
<div>
<div class="memory-section-title">Net Worth Snapshot</div>
<div id="mem-networth" style="font-size: 12px; color: var(--text2)">
</div>
<svg
class="nw-sparkline"
id="mem-sparkline"
preserveAspectRatio="none"
style="display: block; margin-top: 6px; width: 100%; height: 40px"
viewBox="0 0 40 18"
></svg>
</div>
<button class="memory-forget-btn" onclick="clearMemory()">
🗑 Clear all memory
</button>
</div>
<!-- ── Add custom shortcut modal ── -->
<div class="modal-overlay" id="add-card-modal">
<div class="modal-box" style="max-width: 360px">
<div class="modal-title">
+ Add Quick Shortcut
<button class="modal-close-btn" onclick="closeAddCardModal()">
</button>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<label style="font-size: 12px; color: var(--text2)"
>Icon (emoji)
<input
id="card-icon-input"
maxlength="4"
placeholder="🏠"
style="
width: 100%;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 20px;
outline: none;
text-align: center;
"
type="text"
/>
</label>
<label style="font-size: 12px; color: var(--text2)"
>Label
<input
id="card-label-input"
placeholder="e.g. My Portfolio Check"
style="
width: 100%;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
"
type="text"
/>
</label>
<label style="font-size: 12px; color: var(--text2)"
>Query to send
<input
id="card-query-input"
placeholder="e.g. Give me a full portfolio summary"
style="
width: 100%;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
"
type="text"
/>
</label>
<button
onclick="saveCustomCard()"
style="
padding: 9px;
border-radius: 9px;
border: none;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
"
>
Add Shortcut
</button>
</div>
</div>
</div>
<!-- ── Goal setter modal ── -->
<div class="modal-overlay" id="goal-modal">
<div class="modal-box" style="max-width: 360px">
<div class="modal-title">
🎯 Set Financial Goal
<button class="modal-close-btn" onclick="closeGoalModal()"></button>
</div>
<div style="display: flex; flex-direction: column; gap: 10px">
<label style="font-size: 12px; color: var(--text2)"
>Target net worth ($)
<input
id="goal-target-input"
placeholder="e.g. 500000"
style="
width: 100%;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
"
type="number"
/>
</label>
<label style="font-size: 12px; color: var(--text2)"
>Target year
<input
id="goal-year-input"
max="2060"
min="2025"
placeholder="e.g. 2030"
style="
width: 100%;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
outline: none;
"
type="number"
/>
</label>
<button
onclick="saveGoal()"
style="
padding: 9px;
border-radius: 9px;
border: none;
background: linear-gradient(135deg, var(--indigo), #8b5cf6);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
"
>
Set Goal
</button>
</div>
</div>
</div>
<script>
// ── State ──
const STORAGE_KEY = 'gf_agent_chat';
let history = []; // [{role, content}]
let pendingWrite = null; // echoed back to agent on confirmation
let sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
let lastQuery = ''; // for retry
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const sendBtn = document.getElementById('send-btn');
const emptyEl = document.getElementById('empty');
const latChip = document.getElementById('latency-chip');
const toastEl = document.getElementById('session-toast');
const TOOL_CATALOG = [
{
name: 'portfolio_analysis',
desc: 'Fetch your full portfolio snapshot from Ghostfolio — value, returns, holdings breakdown.'
},
{
name: 'transaction_query',
desc: 'Query activity and transaction history with optional ticker filter.'
},
{
name: 'compliance_check',
desc: 'Run concentration and risk-allocation rules against your current holdings.'
},
{
name: 'market_data',
desc: 'Get a live stock price from Yahoo Finance for any symbol.'
},
{
name: 'market_overview',
desc: 'Fetch a broad market summary: top movers, sector performance.'
},
{
name: 'tax_estimate',
desc: 'Estimate capital gains tax liability based on realized P&L.'
},
{
name: 'write_transaction',
desc: 'Record a BUY, SELL, DIVIDEND, or CASH transaction into Ghostfolio (requires confirmation).'
},
{
name: 'transaction_categorize',
desc: 'Analyze your trading patterns — frequency, style, category breakdown.'
},
{
name: 'real_estate',
desc: 'Market data and neighborhood analysis for your investment research — median prices, rental yields, cap rates, and days on market for Austin-area counties and major US metros.'
},
{
name: 'property_tracker',
desc: 'Track properties you own — equity, appreciation, mortgage balance, and net worth alongside your investment portfolio.'
}
];
// ── Auth guard — redirect to login if no token ──
const _token = localStorage.getItem('gf_token');
if (!_token) {
window.location.replace('/login');
}
// ── Load user profile from localStorage (set at login) ──
(function loadUser() {
const name = localStorage.getItem('gf_user_name') || 'Investor';
const initials = name.slice(0, 2).toUpperCase();
document.getElementById('user-avatar').textContent = initials;
document.getElementById('user-name').textContent = name;
})();
// ── Restore session from localStorage ──
(function restoreSession() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
const { hist, stats } = JSON.parse(saved);
if (!hist || hist.length === 0) return;
history = hist;
if (stats) sessionStats = stats;
emptyEl.style.display = 'none';
// Messages appear silently — no notification needed
// Replay messages into DOM
for (let i = 0; i < hist.length; i += 2) {
const u = hist[i];
const a = hist[i + 1];
if (u) addMessage('user', u.content, null, true);
if (a) addMessage('agent', a.content, (a && a.meta) ? a.meta : null, true);
}
} catch {
/* silently skip */
}
})();
function saveSession() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ hist: history, stats: sessionStats })
);
} catch {
/* quota exceeded */
}
}
// ── Textarea auto-resize + Enter to send ──
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
});
function sendQuick(text) {
input.value = text;
send();
}
// ── /tools easter egg ──
function handleToolsCommand() {
emptyEl.style.display = 'none';
addMessage('user', '/tools');
const panel = document.createElement('div');
panel.className = 'message agent';
const inner = document.createElement('div');
inner.className = 'tools-panel';
inner.innerHTML =
`<h3>🔧 Available Agent Tools</h3>` +
TOOL_CATALOG.map(
(t) =>
`<div class="tool-entry"><span class="tool-entry-name">${t.name}</span><span class="tool-entry-desc">${t.desc}</span></div>`
).join('');
panel.appendChild(inner);
chat.appendChild(panel);
chat.scrollTop = chat.scrollHeight;
}
// ── Main send ──
async function send() {
const query = input.value.trim();
if (!query || sendBtn.disabled) return;
if (query === '/tools') {
input.value = '';
input.style.height = 'auto';
handleToolsCommand();
return;
}
// Append file content to query if attached
let finalQuery = query;
if (attachedFileContent) {
finalQuery = `${query}\n\n[Attached file: ${attachedFileName}]\n\`\`\`\n${attachedFileContent.slice(0, 8000)}\n\`\`\``;
attachedFileContent = null;
attachedFileName = null;
attachPreview.classList.remove('visible');
}
// Prepend response-length instruction
if (LENGTH_PREFIXES[responseLength]) {
finalQuery = LENGTH_PREFIXES[responseLength] + finalQuery;
}
// Prepend scenario-mode framing
if (scenarioMode) {
finalQuery = 'In a hypothetical scenario (not real advice): ' + finalQuery;
}
// Track last query time for greeting banner
localStorage.setItem('gf_last_query_ts', Date.now().toString());
document.getElementById('greeting-banner').classList.remove('show');
// Save to query history
saveQueryHistory(query);
closeQueryHistory();
// Inject memory context prefix if available
const memCtx = getMemoryContextPrefix();
if (memCtx) finalQuery = memCtx + finalQuery;
lastQuery = finalQuery;
input.value = '';
input.style.height = 'auto';
sendBtn.disabled = true;
emptyEl.style.display = 'none';
maybeSetTitle(query);
addMessage('user', query);
// Live thinking panel
const thinkingEl = createThinkingPanel();
chat.appendChild(thinkingEl);
chat.scrollTop = chat.scrollHeight;
let metaData = null;
let responseText = '';
let agentBubbleEl = null;
let agentMsgEl = null;
try {
const _authToken = localStorage.getItem('gf_token') || '';
const res = await fetch('/chat/steps', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_authToken}`
},
body: JSON.stringify({
query: finalQuery,
history,
pending_write: pendingWrite
})
});
if (res.status === 401) {
localStorage.removeItem('gf_token');
localStorage.removeItem('gf_user_name');
localStorage.removeItem('gf_user_email');
window.location.replace('/login');
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let evt;
try {
evt = JSON.parse(line.slice(6));
} catch {
continue;
}
if (evt.type === 'step') {
updateThinkingPanel(thinkingEl, evt);
} else if (evt.type === 'meta') {
metaData = evt;
pendingWrite = evt.pending_write || null;
updateLatency(evt.latency_seconds);
sessionStats.toolCalls += (evt.tools_used || []).length;
if (evt.latency_seconds)
sessionStats.latencies.push(evt.latency_seconds);
} else if (evt.type === 'token') {
if (!agentMsgEl) {
thinkingEl.remove();
agentMsgEl = document.createElement('div');
agentMsgEl.className = 'message agent';
agentBubbleEl = document.createElement('div');
agentBubbleEl.className = 'bubble';
agentBubbleEl.style.paddingRight = '184px'; // space for 6 buttons
agentMsgEl.appendChild(agentBubbleEl);
chat.appendChild(agentMsgEl);
}
responseText += evt.token;
agentBubbleEl.innerHTML =
renderBubble(responseText) + agentBubbleButtons('live');
chat.scrollTop = chat.scrollHeight;
} else if (evt.type === 'done') {
if (agentMsgEl && metaData) {
// Assign a real favId now that we have the full response
const realFavId = 'fav_' + Date.now();
if (agentBubbleEl) {
const btn = agentBubbleEl.querySelector('.star-btn');
if (btn) btn.setAttribute('data-fav-id', realFavId);
btn && (btn._responseText = responseText);
}
// Add reaction row + annotation wrap to streaming agent message
const streamMsgHash = 'msg_' + realFavId;
const streamReactionRow = document.createElement('div');
streamReactionRow.className = 'reaction-row';
streamReactionRow.innerHTML = `<button class="reaction-btn up" data-msg="${streamMsgHash}" onclick="reactMsg(this,'up')" title="Helpful">👍</button><button class="reaction-btn down" data-msg="${streamMsgHash}" onclick="reactMsg(this,'down')" title="Not helpful">👎</button>`;
agentMsgEl.appendChild(streamReactionRow);
const streamAnnotWrap = document.createElement('div');
streamAnnotWrap.className = 'annotation-wrap';
streamAnnotWrap.dataset.msgHash = streamMsgHash;
streamAnnotWrap.innerHTML = `<textarea class="annotation-textarea" placeholder="📝 Add a private note to this response…" onblur="saveAnnotation('${streamMsgHash}', this.value)"></textarea>`;
agentMsgEl.appendChild(streamAnnotWrap);
// Wire annotation button
const annotBtn = agentBubbleEl.querySelector('.annotation-btn');
if (annotBtn) annotBtn.setAttribute('onclick', `toggleAnnotation(this)`);
appendMessageFooter(agentMsgEl, metaData, responseText);
appendFollowupChips(agentMsgEl, metaData.tools_used || [], responseText);
updateNetworthBadge(metaData.tools_used || []);
updateContextStrip(metaData.tools_used || []);
}
// Log to request inspector
if (metaData) logRequest(query, metaData);
if (autoSpeak && responseText) speakText(responseText);
// Update goal tracker + memory if net worth mentioned
const nw = extractNetWorthFromResponse(responseText);
if (nw) { updateGoalChip(nw); updateMemoryNetWorth(nw); }
// Extract tickers from response into memory
extractTickersIntoMemory(responseText);
history.push({ role: 'user', content: query });
history.push({ role: 'assistant', content: responseText, meta: metaData || null });
sessionStats.messages++;
saveSession();
saveCurrentSession();
if (typeof updateChatsBadge === 'function') updateChatsBadge();
} else if (evt.type === 'error') {
thinkingEl.remove();
addErrorMessage(evt.message, query);
}
}
}
} catch (err) {
thinkingEl.remove();
if (agentMsgEl) agentMsgEl.remove();
addErrorMessage(
'Could not reach the agent. Please try again.',
query
);
} finally {
sendBtn.disabled = false;
input.focus();
}
}
// ── Thinking panel ──
function createThinkingPanel() {
const wrap = document.createElement('div');
wrap.className = 'message agent';
const panel = document.createElement('div');
panel.className = 'thinking-panel';
panel.innerHTML = `<div class="thinking-header">Agent is thinking…</div><div class="step-list" id="step-list-${Date.now()}"></div>`;
wrap.appendChild(panel);
return wrap;
}
function updateThinkingPanel(wrapEl, evt) {
const list = wrapEl.querySelector('.step-list');
if (!list) return;
// Update header
const header = wrapEl.querySelector('.thinking-header');
if (header)
header.textContent =
evt.status === 'running' ? `${evt.label}` : 'Agent thinking…';
let existing = list.querySelector(`[data-node="${evt.node}"]`);
if (!existing) {
existing = document.createElement('div');
existing.className = 'step-item';
existing.setAttribute('data-node', evt.node);
list.appendChild(existing);
}
const iconHtml =
evt.status === 'running'
? '<div class="step-icon running"></div>'
: '<div class="step-icon done">✓</div>';
let toolsHtml = '';
if (evt.tools && evt.tools.length) {
toolsHtml = `<span class="step-tools">${evt.tools.join(', ')}</span>`;
}
existing.innerHTML = `${iconHtml}<span class="step-label">${evt.label}</span>${toolsHtml}`;
chat.scrollTop = chat.scrollHeight;
}
// ── Render helpers ──
// Configure marked: safe, no pedantic, GFM tables
if (typeof marked !== 'undefined') {
marked.setOptions({ gfm: true, breaks: true, pedantic: false });
}
function renderBubble(text) {
const stripped = text
.replace(/\[source:[^\]]+\]/g, '')
// Strip internal backend citation IDs like [portfolio_1234], [compliance_1234], etc.
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
// Strip any remaining [word_digits] patterns that are clearly internal refs
.replace(/\[[a-z_]+_\d{6,}\]/g, '');
if (typeof marked !== 'undefined') {
return marked.parse(stripped);
}
// Fallback if CDN fails
const escaped = stripped
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return escaped.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
}
function agentBubbleButtons(savedId) {
const isStarred = favorites.some(f => f.id === savedId);
return `<button class="md-copy-btn" title="Copy as Markdown" onclick="copyBubbleMd(this)">MD</button><button class="pin-bubble-btn" title="Pin to top" onclick="pinBubble(this)">📌</button><button class="annotation-btn" title="Add sticky note" onclick="toggleAnnotation(this)">📝</button><button class="star-btn${isStarred ? ' starred' : ''}" title="Save response" onclick="toggleFavorite(this)" data-fav-id="${savedId}">★</button><button class="speak-btn" title="Read aloud" onclick="speakBubble(this)">🔊</button><button class="copy-btn" title="Copy response" onclick="copyBubble(this)"><span class="copy-icon">⎘</span><span class="copy-label">Copy</span></button>`;
}
function copyBubble(btn) {
const bubble = btn.parentElement;
// Get clean text: strip button chars and HTML tags
const clone = bubble.cloneNode(true);
clone.querySelectorAll('button').forEach(b => b.remove());
const text = clone.innerText.trim();
navigator.clipboard.writeText(text).then(() => {
btn.classList.add('copied');
btn.innerHTML = '<span class="copy-icon">✓</span><span class="copy-label">Copied!</span>';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<span class="copy-icon">⎘</span><span class="copy-label">Copy</span>';
}, 1800);
});
}
// ── Text-to-speech ──
let autoSpeak = false;
function setAutoSpeak(val) {
autoSpeak = val;
const pill = document.getElementById('speak-pill-settings');
if (pill) pill.classList.toggle('on', autoSpeak);
try { localStorage.setItem('gf_autospeak', autoSpeak ? '1' : '0'); } catch {}
}
// Restore saved preference
if (localStorage.getItem('gf_autospeak') === '1') setAutoSpeak(true);
document.getElementById('speak-toggle-settings').addEventListener('click', (e) => {
e.stopPropagation();
setAutoSpeak(!autoSpeak);
});
function speakText(text) {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
const clean = text
.replace(/\[source:[^\]]+\]/g, '')
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
.replace(/\[[a-z_]+_\d{6,}\]/g, '')
.replace(/#{1,6}\s/g, '')
.replace(/\*\*/g, '').replace(/\*/g, '')
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/\|/g, ' ')
.replace(/[-─]{3,}/g, '')
.replace(/📊|🏠|💰|🔧|✓|⎘|🔊|➤|🎙/g, '')
.replace(/\n+/g, ' ').trim();
const utt = new SpeechSynthesisUtterance(clean);
utt.rate = 1.0;
utt.pitch = 1.0;
window.speechSynthesis.speak(utt);
}
function speakBubble(btn) {
if (!('speechSynthesis' in window)) return;
if (window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
btn.classList.remove('speaking');
btn.textContent = '🔊';
return;
}
const bubble = btn.parentElement;
const clone = bubble.cloneNode(true);
clone.querySelectorAll('button').forEach(b => b.remove());
const text = clone.innerText.trim();
btn.classList.add('speaking');
btn.textContent = '⏹';
const utt = new SpeechSynthesisUtterance(text.replace(/\n+/g, ' '));
utt.onend = () => { btn.classList.remove('speaking'); btn.textContent = '🔊'; };
utt.onerror = () => { btn.classList.remove('speaking'); btn.textContent = '🔊'; };
window.speechSynthesis.speak(utt);
}
function addMessage(role, text, meta = null, restored = false) {
emptyEl.style.display = 'none';
const wrap = document.createElement('div');
wrap.className = `message ${role}`;
const bubble = document.createElement('div');
bubble.className = 'bubble';
if (role === 'agent') {
bubble.style.paddingRight = '184px'; // space for 6 buttons
const favId = 'fav_' + Date.now() + '_' + Math.random().toString(36).slice(2);
bubble.innerHTML = renderBubble(text) + agentBubbleButtons(favId);
// Reaction row + annotation wrap
const reactionRow = document.createElement('div');
reactionRow.className = 'reaction-row';
const msgHash = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
reactionRow.innerHTML = `<button class="reaction-btn up" data-msg="${msgHash}" onclick="reactMsg(this,'up')" title="Helpful">👍</button><button class="reaction-btn down" data-msg="${msgHash}" onclick="reactMsg(this,'down')" title="Not helpful">👎</button>`;
wrap.appendChild(bubble);
wrap.appendChild(reactionRow);
const annotWrap = document.createElement('div');
annotWrap.className = 'annotation-wrap';
annotWrap.dataset.msgHash = msgHash;
const savedNote = getAnnotation(msgHash);
annotWrap.innerHTML = `<textarea class="annotation-textarea" placeholder="📝 Add a private note to this response…" onblur="saveAnnotation('${msgHash}', this.value)">${savedNote}</textarea>`;
if (savedNote) { annotWrap.classList.add('open'); bubble.querySelector('.annotation-btn') && bubble.querySelector('.annotation-btn').classList.add('has-note'); }
wrap.appendChild(annotWrap);
} else {
bubble.innerHTML = renderBubble(text);
wrap.appendChild(bubble);
}
// Edit button under user messages
if (role === 'user') {
const editBtn = document.createElement('button');
editBtn.className = 'edit-btn';
editBtn.textContent = '✏ Edit';
editBtn.onclick = () => editMessage(wrap, text);
wrap.appendChild(editBtn);
}
if (role === 'agent' && meta) {
appendMessageFooter(wrap, meta, text);
}
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
return wrap;
}
// ── Edit & resend ──
function editMessage(userMsgEl, originalText) {
// Find the index of this message in history
const allMsgs = [...chat.querySelectorAll('.message')];
const msgIndex = allMsgs.indexOf(userMsgEl);
// Remove this user bubble + the agent reply after it from DOM
const toRemove = allMsgs.slice(msgIndex);
toRemove.forEach(el => el.remove());
// Also remove follow-up chip rows that might follow
chat.querySelectorAll('.followup-row').forEach(r => r.remove());
// Trim history to just before this message
// Each visible exchange = 2 history entries (user + agent)
// Count user messages before this one
const userMsgsBefore = allMsgs.slice(0, msgIndex).filter(el => el.classList.contains('user')).length;
history = history.slice(0, userMsgsBefore * 2);
// Put text back in input
input.value = originalText;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
input.focus();
}
function appendMessageFooter(wrapEl, meta, text) {
const footer = document.createElement('div');
footer.className = 'msg-footer';
// Row 1: tool badges + verification badge
const badgeRow = document.createElement('div');
badgeRow.className = 'badge-row';
(meta.tools_used || []).forEach((t) => {
const b = document.createElement('span');
b.className = 'badge tool';
b.innerHTML = `<span>🔧</span>${t}`;
badgeRow.appendChild(b);
});
if (meta.verification_outcome) {
const isPass = meta.verification_outcome === 'pass';
const isFlag = meta.verification_outcome === 'flag';
const b = document.createElement('span');
b.className = `badge ${isPass ? 'pass' : isFlag ? 'flag' : 'fail'}`;
b.textContent = isPass
? '✓ Verified'
: isFlag
? '⚠ Flagged'
: '✕ Failed';
badgeRow.appendChild(b);
}
if (meta.latency_seconds != null) {
const b = document.createElement('span');
b.className = 'badge time';
b.textContent = `${meta.latency_seconds}s`;
badgeRow.appendChild(b);
}
footer.appendChild(badgeRow);
// Row 2: confidence bar + timestamp
if (meta.confidence_score != null) {
const confRow = document.createElement('div');
confRow.className = 'badge-row';
const pct = Math.round(meta.confidence_score * 100);
const cls =
meta.confidence_score >= 0.8
? 'high'
: meta.confidence_score >= 0.5
? 'med'
: 'low';
confRow.innerHTML = `
<div class="confidence-bar-wrap">
<span class="confidence-bar-label">Confidence</span>
<div class="confidence-bar-track">
<div class="confidence-bar-fill ${cls}" style="width:${pct}%"></div>
</div>
<span class="confidence-bar-label">${pct}%</span>
</div>
<span class="msg-ts">${formatTs()}</span>`;
footer.appendChild(confRow);
}
// Confirmation banner
if (meta.awaiting_confirmation && meta.pending_write) {
const banner = document.createElement('div');
banner.className = 'confirm-banner';
banner.textContent =
'⚠️ Awaiting your confirmation — reply yes or no.';
footer.appendChild(banner);
}
// Debug panel
const details = document.createElement('details');
details.className = 'debug-panel';
const toolList = (meta.tools_used || []).join(', ') || 'none';
const confPct =
meta.confidence_score != null
? Math.round(meta.confidence_score * 100) + '%'
: '—';
const confCls =
meta.confidence_score >= 0.8
? 'high'
: meta.confidence_score >= 0.5
? 'med'
: 'low';
const outCls =
meta.verification_outcome === 'pass'
? 'pass'
: meta.verification_outcome === 'flag'
? 'flag'
: 'fail';
details.innerHTML = `
<summary>🔍 Metadata</summary>
<div class="debug-body">
<div class="db-row"><span class="db-key">tools_called</span><span class="db-val">${toolList}</span></div>
<div class="db-row"><span class="db-key">verification</span><span class="db-val ${outCls}">${meta.verification_outcome || '—'}</span></div>
<div class="db-row"><span class="db-key">confidence</span><span class="db-val ${confCls}">${confPct}</span></div>
<div class="db-row"><span class="db-key">latency</span><span class="db-val">${meta.latency_seconds != null ? meta.latency_seconds + 's' : (meta.latency_ms != null ? meta.latency_ms + 'ms' : '—')}</span></div>
<div class="db-row"><span class="db-key">citations</span><span class="db-val">${(meta.citations || []).join(', ') || 'none'}</span></div>
</div>`;
details.open = true; // Expanded by default so metadata is visible
footer.appendChild(details);
wrapEl.appendChild(footer);
}
function addErrorMessage(msg, origQuery) {
const wrap = document.createElement('div');
wrap.className = 'message agent';
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerHTML = `${renderBubble(msg)}`;
wrap.appendChild(bubble);
const retryBtn = document.createElement('button');
retryBtn.className = 'retry-btn';
retryBtn.textContent = '↻ Retry';
retryBtn.onclick = () => {
wrap.remove();
input.value = origQuery;
send();
};
wrap.appendChild(retryBtn);
chat.appendChild(wrap);
chat.scrollTop = chat.scrollHeight;
}
// ── Latency tracker ──
function updateLatency(sec) {
if (sec == null) return;
latChip.classList.remove('hidden');
latChip.textContent = `last: ${sec}s`;
}
// ── Timestamp helper ──
function formatTs() {
const now = new Date();
return now.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
}
// ── Voice input (Web Speech API) ──
let recognition = null;
let isListening = false;
const micBtn = document.getElementById('mic-btn');
if (!('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
micBtn.style.display = 'none';
} else {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SR();
recognition.lang = 'en-US';
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.onresult = (e) => {
const transcript = Array.from(e.results)
.map((r) => r[0].transcript)
.join('');
input.value = transcript;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
if (e.results[e.results.length - 1].isFinal) stopListening();
};
recognition.onerror = () => stopListening();
recognition.onend = () => stopListening();
micBtn.addEventListener('click', () => {
if (!recognition) return;
isListening ? recognition.stop() : startListening();
});
}
function startListening() {
isListening = true;
micBtn.classList.add('listening');
micBtn.title = 'Stop listening';
input.placeholder = 'Listening…';
recognition.start();
}
function stopListening() {
isListening = false;
micBtn.classList.remove('listening');
micBtn.title = 'Voice input';
input.placeholder = 'Ask anything about your portfolio… (type /tools to see available tools)';
}
// ── Follow-up suggestion chips (adaptive — parses response text) ──
function getFollowupChips(toolsUsed, responseText) {
const r = (responseText || '').toLowerCase();
const chips = new Set();
// ── Content-aware: scan response for signals ──
if (/down\s+\d+%|declin|fell|dropp|lower/.test(r)) {
chips.add('When is a good time to buy in this market?');
}
if (/up\s+\d+%|increas|appreciat|gain|grew/.test(r)) {
chips.add('How does this compare to inflation?');
}
if (/equity|cash[\s-]out|refinanc/.test(r)) {
chips.add('Should I do a cash-out refinance?');
}
if (/days on market|dom\b/.test(r) && /\b[5-9]\d\b|\b1\d{2,}\b/.test(r)) {
chips.add('Is this a buyer\'s or seller\'s market?');
}
if (/net worth|total worth/.test(r)) {
chips.add('What if Austin prices drop 10%? How does that affect my net worth?');
}
if (/loss|down\s+\$|negative/.test(r)) {
chips.add('What is my tax-loss harvesting opportunity?');
}
if (/rental|rent/.test(r)) {
chips.add('What\'s the rental yield on my property vs. the market average?');
}
if (/mortgage|balance/.test(r)) {
chips.add('Am I building equity faster than the market is appreciating?');
}
if (/portfolio|holding|allocation/.test(r)) {
chips.add('Am I on track to retire at 60 at this savings rate?');
}
if (/concentrated|overweight|position/.test(r)) {
chips.add('How can I rebalance to reduce concentration risk?');
}
if (/tax|capital gain/.test(r)) {
chips.add('What trades triggered the largest tax liability?');
}
if (/ytd|year.to.date|return/.test(r)) {
chips.add('How does my return compare to the S&P 500?');
}
if (/afford|price.to.income|ratio/.test(r)) {
chips.add('Which county has the best affordability right now?');
}
if (/inventory|months of/.test(r)) {
chips.add('What does high inventory mean for buyers vs. sellers?');
}
// ── Tool-based fallbacks (used when text parsing finds nothing) ──
const toolFallbacks = {
real_estate: ['Add this to my portfolio', 'What\'s the rental yield on this property?', 'How does this affect my net worth?'],
property_tracker: ['Compare to my other properties', 'What\'s my total real estate equity?', 'How does real estate fit my overall allocation?'],
compliance_check: ['How can I rebalance?', 'What is my YTD return?', 'Show my biggest holdings'],
portfolio_analysis: ['Am I over-concentrated?', 'Estimate my tax liability', 'Show my recent trades'],
market_data: ['Compare this to my portfolio performance', 'What is my YTD return?', 'Show me SPY price'],
market_overview: ['What sectors are outperforming?', 'How is this affecting my portfolio?', 'Show me my biggest holding'],
tax_estimate: ['Show my capital gains breakdown', 'What trades triggered this?', 'Run compliance check'],
write_transaction: ['Show my updated portfolio', 'What is my new allocation?', 'Check compliance after this trade'],
transaction_query: ['What is my total return on these?', 'Show me my portfolio summary', 'Am I over-concentrated?']
};
// Fill from content-aware chips first, then pad with tool fallbacks
const result = [...chips];
if (result.length < 3) {
for (const tool of toolsUsed) {
for (const chip of (toolFallbacks[tool] || [])) {
if (!result.includes(chip)) result.push(chip);
if (result.length >= 3) break;
}
if (result.length >= 3) break;
}
}
return result.slice(0, 3);
}
function appendFollowupChips(parentEl, toolsUsed, responseText) {
const chips = getFollowupChips(toolsUsed, responseText);
if (!chips.length) return;
const row = document.createElement('div');
row.className = 'followup-row';
chips.forEach((text) => {
const btn = document.createElement('button');
btn.className = 'followup-chip';
btn.textContent = text;
btn.onclick = () => {
row.remove();
input.value = text;
send();
};
row.appendChild(btn);
});
parentEl.appendChild(row);
}
// ── Net worth banner ──
function updateNetworthBadge(toolsUsed) {
const badge = document.getElementById('networth-badge');
if (toolsUsed.includes('property_tracker')) {
badge.style.display = 'flex';
}
}
// ── Download conversation as .txt ──
function downloadConversation() {
if (history.length === 0) { showToast('No conversation to export yet.'); return; }
const lines = [];
const ts = new Date().toLocaleString();
lines.push('Ghostfolio AI Agent — Conversation Export');
lines.push(`Exported: ${ts}`);
lines.push('─'.repeat(60));
lines.push('');
for (const msg of history) {
lines.push(msg.role === 'user' ? '👤 You:' : '🤖 Agent:');
lines.push(msg.content
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
.replace(/\[[a-z_]+_\d{6,}\]/g, ''));
lines.push('');
}
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ghostfolio-chat-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
setTimeout(() => toastEl.classList.remove('show'), 2500);
}
// ── Keyboard shortcuts ──
document.addEventListener('keydown', (e) => {
const meta = e.metaKey || e.ctrlKey;
// Cmd+K → focus input
if (meta && e.key === 'k') {
e.preventDefault();
input.focus();
return;
}
// Cmd+Shift+E → export conversation as .txt
if (meta && e.shiftKey && e.key === 'e') {
e.preventDefault();
downloadConversation();
return;
}
// Cmd+/ → /tools panel
if (meta && e.key === '/') {
e.preventDefault();
input.value = '/tools';
send();
return;
}
// ↑ arrow when input is empty → restore last sent message
if (e.key === 'ArrowUp' && document.activeElement === input && input.value === '') {
e.preventDefault();
const lastUser = [...history].reverse().find(m => m.role === 'user');
if (lastUser) {
input.value = lastUser.content;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
}
return;
}
// Escape → stop mic / clear input
if (e.key === 'Escape') {
if (isListening && recognition) recognition.stop();
if (input.value) { input.value = ''; input.style.height = 'auto'; }
if (window.speechSynthesis && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
}
});
// ── Session history (multi-session localStorage) ──
const SESSIONS_KEY = 'gf_sessions_v1';
const ACTIVE_SESSION_KEY = 'gf_active_session';
const MAX_SESSIONS = 15;
// Restore the session ID from the previous page load so saves stay linked
// to the same entry in gf_sessions_v1 rather than creating a duplicate.
let currentSessionId = localStorage.getItem(ACTIVE_SESSION_KEY) || Date.now().toString();
let currentSessionTitle = null;
function getSessions() {
try { return JSON.parse(localStorage.getItem(SESSIONS_KEY)) || []; } catch { return []; }
}
function saveSessions(sessions) {
try { localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); } catch {}
}
function saveCurrentSession() {
if (history.length === 0) return;
const title = currentSessionTitle || (history[0] && history[0].content.slice(0, 45)) || 'Untitled';
const sessions = getSessions().filter(s => s.id !== currentSessionId);
sessions.unshift({
id: currentSessionId,
title,
date: new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
messages: history.slice()
});
saveSessions(sessions.slice(0, MAX_SESSIONS));
}
function startNewChat() {
saveCurrentSession();
currentSessionId = Date.now().toString();
localStorage.setItem(ACTIVE_SESSION_KEY, currentSessionId);
localStorage.setItem('gf_new_chat', '1'); // user explicitly started fresh
currentSessionTitle = null;
history = [];
pendingWrite = null;
sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
localStorage.removeItem(STORAGE_KEY);
chat.innerHTML = '';
chat.appendChild(emptyEl);
emptyEl.style.display = '';
latChip.classList.add('hidden');
document.title = 'Ghostfolio AI Agent';
document.getElementById('networth-badge').style.display = 'none';
resetContextStrip();
if (typeof updateHeaderTitle === 'function') updateHeaderTitle();
if (typeof updateChatsBadge === 'function') updateChatsBadge();
closeDrawer();
}
function loadSession(sess) {
saveCurrentSession();
currentSessionId = sess.id;
localStorage.setItem(ACTIVE_SESSION_KEY, sess.id);
localStorage.removeItem('gf_new_chat'); // resume clears the new-chat flag
currentSessionTitle = sess.title;
history = sess.messages.slice();
pendingWrite = null;
sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
chat.innerHTML = '';
chat.appendChild(emptyEl);
emptyEl.style.display = 'none';
for (let i = 0; i < history.length; i += 2) {
if (history[i]) addMessage('user', history[i].content, null, true);
if (history[i + 1]) {
const a = history[i + 1];
addMessage('agent', a.content, (a && a.meta) ? a.meta : null, true);
}
}
document.title = sess.title + ' — Ghostfolio';
if (typeof updateHeaderTitle === 'function') updateHeaderTitle();
closeDrawer();
}
function deleteSession(id, e) {
e.stopPropagation();
const sessions = getSessions().filter(s => s.id !== id);
saveSessions(sessions);
renderDrawer();
}
function renderDrawer() {
const list = document.getElementById('session-list');
const sessions = getSessions();
if (sessions.length === 0) {
list.innerHTML = '<div class="drawer-empty">No saved chats yet.<br>Start chatting and your<br>sessions will appear here.</div>';
return;
}
list.innerHTML = sessions.map(s => `
<div class="session-item${s.id === currentSessionId ? ' active' : ''}" onclick="loadSession(${JSON.stringify(s)})">
<span class="session-item-actions">
<button class="session-del-btn" onclick="deleteSession('${s.id}', event)" title="Delete">✕</button>
</span>
<div class="session-title">${escapeHtml(s.title)}</div>
<div class="session-meta">${s.date} · ${Math.floor(s.messages.length / 2)} msg${s.messages.length !== 2 ? 's' : ''}</div>
</div>`).join('');
}
function escapeHtml(t) {
return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function openDrawer() {
renderDrawer();
document.getElementById('sessions-drawer').classList.add('open');
document.getElementById('drawer-overlay').classList.add('open');
}
function closeDrawer() {
document.getElementById('sessions-drawer').classList.remove('open');
document.getElementById('drawer-overlay').classList.remove('open');
}
document.getElementById('chats-btn').addEventListener('click', openDrawer);
document.getElementById('drawer-close').addEventListener('click', closeDrawer);
document.getElementById('drawer-overlay').addEventListener('click', closeDrawer);
document.getElementById('drawer-new-btn').addEventListener('click', startNewChat);
// Auto-set session title from first user message
function maybeSetTitle(userMsg) {
if (currentSessionTitle) return;
currentSessionTitle = (typeof smartTitle === 'function') ? smartTitle(userMsg) : (userMsg.slice(0, 50) + (userMsg.length > 50 ? '…' : ''));
document.title = currentSessionTitle + ' — Ghostfolio';
if (typeof updateHeaderTitle === 'function') updateHeaderTitle();
}
// ── Pinned context strip ──
const ctxTags = {
portfolio: document.getElementById('ctx-portfolio'),
realestate: document.getElementById('ctx-realestate'),
property: document.getElementById('ctx-property'),
market: document.getElementById('ctx-market'),
compliance: document.getElementById('ctx-compliance'),
tax: document.getElementById('ctx-tax')
};
const contextStrip = document.getElementById('context-strip');
const TOOL_TO_CTX = {
portfolio_analysis: 'portfolio',
transaction_query: 'portfolio',
write_transaction: 'portfolio',
transaction_categorize: 'portfolio',
real_estate: 'realestate',
property_tracker: 'property',
market_data: 'market',
market_overview: 'market',
compliance_check: 'compliance',
tax_estimate: 'tax'
};
function updateContextStrip(toolsUsed) {
let anyActive = false;
toolsUsed.forEach(tool => {
const ctxKey = TOOL_TO_CTX[tool];
if (ctxKey && ctxTags[ctxKey]) {
ctxTags[ctxKey].classList.add('active');
if (ctxKey === 'property') ctxTags[ctxKey].classList.add('green');
anyActive = true;
}
});
if (anyActive) contextStrip.classList.add('visible');
// Update context-aware input placeholder
if (typeof updatePlaceholder === 'function') updatePlaceholder(toolsUsed);
// Check county alert
if (typeof checkCountyAlert === 'function') checkCountyAlert();
}
function resetContextStrip() {
Object.values(ctxTags).forEach(t => {
t.classList.remove('active', 'green');
});
contextStrip.classList.remove('visible');
}
// ── Conversation search ──
const searchBar = document.getElementById('search-bar');
const searchInput = document.getElementById('search-input');
const searchCount = document.getElementById('search-count');
let searchMatches = [];
let searchIdx = 0;
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault();
openSearch();
}
});
function openSearch() {
searchBar.classList.add('open');
searchInput.focus();
searchInput.select();
}
function closeSearch() {
searchBar.classList.remove('open');
clearSearchHighlights();
searchMatches = [];
searchCount.textContent = '';
searchInput.value = '';
}
function clearSearchHighlights() {
chat.querySelectorAll('mark').forEach(m => {
const parent = m.parentNode;
parent.replaceChild(document.createTextNode(m.textContent), m);
parent.normalize();
});
}
function runSearch(query) {
clearSearchHighlights();
searchMatches = [];
searchIdx = 0;
if (!query.trim()) { searchCount.textContent = ''; return; }
const bubbles = [...chat.querySelectorAll('.bubble')];
const esc = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(esc, 'gi');
bubbles.forEach(bubble => {
const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT);
const nodes = [];
let n;
while ((n = walker.nextNode())) nodes.push(n);
nodes.forEach(node => {
if (!node.textContent.match(re)) return;
const frag = document.createDocumentFragment();
let last = 0;
let m;
re.lastIndex = 0;
while ((m = re.exec(node.textContent)) !== null) {
frag.appendChild(document.createTextNode(node.textContent.slice(last, m.index)));
const mark = document.createElement('mark');
mark.textContent = m[0];
frag.appendChild(mark);
searchMatches.push(mark);
last = re.lastIndex;
}
frag.appendChild(document.createTextNode(node.textContent.slice(last)));
node.parentNode.replaceChild(frag, node);
});
});
highlightCurrent();
searchCount.textContent = searchMatches.length ? `${searchIdx + 1} / ${searchMatches.length}` : 'No results';
}
function highlightCurrent() {
searchMatches.forEach((m, i) => m.classList.toggle('current', i === searchIdx));
if (searchMatches[searchIdx]) {
searchMatches[searchIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
searchCount.textContent = searchMatches.length ? `${searchIdx + 1} / ${searchMatches.length}` : 'No results';
}
searchInput.addEventListener('input', () => runSearch(searchInput.value));
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); navigateSearch(e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') closeSearch();
});
document.getElementById('search-prev').addEventListener('click', () => navigateSearch(-1));
document.getElementById('search-next').addEventListener('click', () => navigateSearch(1));
document.getElementById('search-close').addEventListener('click', closeSearch);
function navigateSearch(dir) {
if (!searchMatches.length) return;
searchIdx = (searchIdx + dir + searchMatches.length) % searchMatches.length;
highlightCurrent();
}
// ── Attachment button ──
let attachedFileContent = null;
let attachedFileName = null;
const attachBtn = document.getElementById('attach-btn');
const attachMenu = document.getElementById('attach-menu');
const attachPreview = document.getElementById('attach-preview');
const attachPreviewName = document.getElementById('attach-preview-name');
const attachFileInput = document.getElementById('attach-file-input');
const attachPhotoInput = document.getElementById('attach-photo-input');
attachBtn.addEventListener('click', (e) => {
e.stopPropagation();
attachMenu.classList.toggle('open');
});
document.addEventListener('click', () => attachMenu.classList.remove('open'));
document.getElementById('attach-file-btn').addEventListener('click', () => {
attachFileInput.click();
attachMenu.classList.remove('open');
});
document.getElementById('attach-photo-btn').addEventListener('click', () => {
attachPhotoInput.click();
attachMenu.classList.remove('open');
});
attachFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
attachedFileContent = ev.target.result;
attachedFileName = file.name;
attachPreviewName.textContent = `📎 ${file.name} (${Math.round(file.size / 1024)}KB)`;
attachPreview.classList.add('visible');
};
reader.readAsText(file);
attachFileInput.value = '';
});
attachPhotoInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
attachedFileName = file.name;
attachedFileContent = `[Image attached: ${file.name}]`;
attachPreviewName.textContent = `📷 ${file.name}`;
attachPreview.classList.add('visible');
attachPhotoInput.value = '';
});
document.getElementById('attach-remove').addEventListener('click', () => {
attachedFileContent = null;
attachedFileName = null;
attachPreview.classList.remove('visible');
});
// ── Settings dropdown ──
const settingsBtn = document.getElementById('settings-btn');
const settingsMenu = document.getElementById('settings-menu');
settingsBtn.addEventListener('click', e => { e.stopPropagation(); settingsMenu.classList.toggle('open'); });
document.addEventListener('click', () => settingsMenu.classList.remove('open'));
// ── Keyboard shortcut modal ──
function openShortcutModal() { document.getElementById('shortcut-modal').classList.add('open'); }
function closeShortcutModal() { document.getElementById('shortcut-modal').classList.remove('open'); }
document.getElementById('shortcuts-btn').addEventListener('click', () => { settingsMenu.classList.remove('open'); openShortcutModal(); });
document.getElementById('shortcut-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeShortcutModal(); });
// Cmd+? shortcut
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === '?') { e.preventDefault(); openShortcutModal(); }
});
// ── Dark / light theme ──
let currentTheme = localStorage.getItem('gf_theme') || 'dark';
function applyTheme(t) {
currentTheme = t;
document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : '');
document.getElementById('theme-pill').classList.toggle('on', t === 'light');
localStorage.setItem('gf_theme', t);
}
applyTheme(currentTheme);
document.getElementById('theme-toggle-btn').addEventListener('click', () => {
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
// ── Scenario mode ──
let scenarioMode = localStorage.getItem('gf_scenario') === '1';
function applyScenarioMode(on) {
scenarioMode = on;
document.getElementById('scenario-pill').classList.toggle('on', on);
document.getElementById('scenario-badge').classList.toggle('active', on);
localStorage.setItem('gf_scenario', on ? '1' : '0');
input.placeholder = on
? 'Ask a "what if" question… e.g. "What if Austin prices drop 15%?"'
: 'Ask anything about your portfolio… (type /tools to see available tools)';
}
applyScenarioMode(scenarioMode);
document.getElementById('scenario-toggle-btn').addEventListener('click', () => applyScenarioMode(!scenarioMode));
document.getElementById('scenario-badge').addEventListener('click', () => applyScenarioMode(false));
// ── Response length toggle ──
let responseLength = 'normal';
const LENGTH_PREFIXES = {
brief: 'Please respond very concisely — use bullet points and keep the answer under 5 sentences. ',
normal: '',
detailed: 'Please give a thorough, detailed response with explanations and context. '
};
function setLength(mode) {
responseLength = mode;
['brief','normal','detailed'].forEach(m => {
document.getElementById(`len-${m}`).classList.toggle('active', m === mode);
});
}
// ── TL;DR session summary ──
document.getElementById('tldr-btn').addEventListener('click', () => {
settingsMenu.classList.remove('open');
if (history.length < 2) { showToast('Nothing to summarize yet.'); return; }
const ctx = history.slice(-10).map(m => `${m.role}: ${m.content.slice(0, 300)}`).join('\n');
input.value = `Summarize this conversation in 5 concise bullet points:\n\n${ctx}`;
send();
});
// ── PDF / print export ──
document.getElementById('print-btn').addEventListener('click', () => {
settingsMenu.classList.remove('open');
window.print();
});
// ── Goal tracker ──
let goalTarget = parseFloat(localStorage.getItem('gf_goal_target')) || 0;
let goalYear = parseInt(localStorage.getItem('gf_goal_year')) || 0;
function openGoalModal() {
settingsMenu.classList.remove('open');
document.getElementById('goal-target-input').value = goalTarget || '';
document.getElementById('goal-year-input').value = goalYear || '';
document.getElementById('goal-modal').classList.add('open');
}
function closeGoalModal() { document.getElementById('goal-modal').classList.remove('open'); }
function saveGoal() {
const t = parseFloat(document.getElementById('goal-target-input').value);
const y = parseInt(document.getElementById('goal-year-input').value);
if (!t || !y) { showToast('Please enter both a target and year.'); return; }
goalTarget = t; goalYear = y;
localStorage.setItem('gf_goal_target', t);
localStorage.setItem('gf_goal_year', y);
closeGoalModal();
updateGoalChip(null);
showToast(`Goal set: $${t.toLocaleString()} by ${y}`);
}
document.getElementById('set-goal-btn').addEventListener('click', openGoalModal);
document.getElementById('goal-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGoalModal(); });
document.getElementById('goal-chip').addEventListener('click', openGoalModal);
function updateGoalChip(netWorth) {
if (!goalTarget) return;
const chip = document.getElementById('goal-chip');
chip.classList.add('visible');
const pct = netWorth ? Math.min(100, Math.round((netWorth / goalTarget) * 100)) : 0;
document.getElementById('goal-bar').style.width = pct + '%';
document.getElementById('goal-pct').textContent = pct + '%';
document.getElementById('goal-label').textContent = `$${(goalTarget/1000).toFixed(0)}k by ${goalYear}`;
if (netWorth) document.getElementById('goal-bar').style.background = pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--yellow)' : 'var(--indigo)';
}
if (goalTarget) updateGoalChip(null);
function extractNetWorthFromResponse(text) {
const m = text.match(/(?:net worth|total worth)[^$]*\$\s*([\d,]+)/i);
if (m) return parseFloat(m[1].replace(/,/g, ''));
return null;
}
// ── Favorite / star responses ──
const FAV_KEY = 'gf_favorites_v1';
let favorites = (() => { try { return JSON.parse(localStorage.getItem(FAV_KEY)) || []; } catch { return []; } })();
function saveFavorites() { try { localStorage.setItem(FAV_KEY, JSON.stringify(favorites.slice(0, 50))); } catch {} }
function toggleFavorite(btn) {
const id = btn.getAttribute('data-fav-id');
const bubble = btn.parentElement;
const clone = bubble.cloneNode(true);
clone.querySelectorAll('button').forEach(b => b.remove());
const text = clone.innerText.trim();
const existing = favorites.findIndex(f => f.id === id);
if (existing >= 0) {
favorites.splice(existing, 1);
btn.classList.remove('starred');
showToast('Removed from saved.');
} else {
favorites.unshift({ id, text, date: new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) });
btn.classList.add('starred');
showToast('⭐ Saved!');
}
saveFavorites();
}
function renderSavedList() {
const list = document.getElementById('saved-list');
if (!favorites.length) {
list.innerHTML = '<div class="drawer-empty">No saved responses yet.<br>Click ★ on any agent reply<br>to save it here.</div>';
return;
}
list.innerHTML = favorites.map(f => `
<div class="saved-item">
<div class="saved-item-text">${escapeHtml(f.text)}</div>
<div class="saved-item-meta">
<span>${f.date}</span>
<button class="saved-del-btn" onclick="deleteFavorite('${f.id}')">✕ Remove</button>
</div>
</div>`).join('');
}
function deleteFavorite(id) {
favorites = favorites.filter(f => f.id !== id);
saveFavorites();
renderSavedList();
// Unstar any matching bubble
const btn = document.querySelector(`.star-btn[data-fav-id="${id}"]`);
if (btn) btn.classList.remove('starred');
}
function switchDrawerTab(tab) {
const isChats = tab === 'chats';
document.getElementById('tab-chats').classList.toggle('active', isChats);
document.getElementById('tab-saved').classList.toggle('active', !isChats);
document.getElementById('session-list').style.display = isChats ? '' : 'none';
document.getElementById('saved-list').style.display = isChats ? 'none' : '';
document.getElementById('drawer-new-btn').style.display = isChats ? '' : 'none';
if (!isChats) renderSavedList();
}
// ── Input templates (triggered by ~ as first char) ──
const templatePicker = document.getElementById('template-picker');
input.addEventListener('keyup', e => {
if (input.value === '~') {
templatePicker.classList.add('open');
} else {
templatePicker.classList.remove('open');
}
});
document.addEventListener('click', e => {
if (!templatePicker.contains(e.target) && e.target !== input) {
templatePicker.classList.remove('open');
}
});
function useTemplate(tmpl) {
templatePicker.classList.remove('open');
input.value = tmpl;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
input.focus();
// Place cursor at first [bracket]
const idx = tmpl.indexOf('[');
if (idx >= 0) input.setSelectionRange(idx, tmpl.indexOf(']') + 1);
}
// ── Proactive greeting banner ──
(function checkGreeting() {
const lastQueryTs = parseInt(localStorage.getItem('gf_last_query_ts')) || 0;
const daysSince = (Date.now() - lastQueryTs) / (1000 * 60 * 60 * 24);
const banner = document.getElementById('greeting-banner');
const userName = localStorage.getItem('gf_user_name') || 'Investor';
const hour = new Date().getHours();
const tod = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
if (!lastQueryTs || daysSince > 2) {
document.getElementById('greeting-text').textContent =
`Good ${tod}, ${userName}!`;
document.getElementById('greeting-sub').textContent = lastQueryTs
? `It's been ${Math.round(daysSince)} days since your last check-in. Want a quick portfolio summary?`
: `Welcome! You can start with a portfolio summary, check your real estate equity, or just type a question.`;
banner.classList.add('show');
document.getElementById('greeting-action').onclick = () => {
banner.classList.remove('show');
input.value = 'Give me a full portfolio summary and highlight anything important';
send();
};
document.getElementById('greeting-dismiss').onclick = () => banner.classList.remove('show');
}
})();
// ── Editable session title in header ──
const headerSubtitle = document.getElementById('header-subtitle');
const headerSubtitleText = document.getElementById('header-subtitle-text');
function updateHeaderTitle() {
headerSubtitleText.textContent = currentSessionTitle || 'Powered by Claude + LangGraph';
}
headerSubtitle.addEventListener('click', () => {
if (!currentSessionTitle) return; // nothing to rename before first message
startInlineHeaderRename();
});
function startInlineHeaderRename() {
const current = headerSubtitleText.textContent;
const inp = document.createElement('input');
inp.className = 'session-title-input';
inp.value = current;
headerSubtitle.style.display = 'none';
headerSubtitle.parentElement.appendChild(inp);
inp.focus();
inp.select();
const finish = () => {
const val = inp.value.trim();
if (val) {
currentSessionTitle = val;
document.title = val + ' — Ghostfolio';
headerSubtitleText.textContent = val;
// Update in sessions store
const sessions = getSessions();
const idx = sessions.findIndex(s => s.id === currentSessionId);
if (idx >= 0) { sessions[idx].title = val; saveSessions(sessions); }
}
inp.remove();
headerSubtitle.style.display = '';
};
inp.addEventListener('blur', finish);
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
if (e.key === 'Escape') { inp.value = current; inp.blur(); }
});
}
// ── Archive storage ──
const ARCHIVE_KEY = 'gf_archived_v1';
function getArchived() { try { return JSON.parse(localStorage.getItem(ARCHIVE_KEY)) || []; } catch { return []; } }
function saveArchived(a) { try { localStorage.setItem(ARCHIVE_KEY, JSON.stringify(a)); } catch {} }
function archiveSession(id) {
const sessions = getSessions();
const idx = sessions.findIndex(s => s.id === id);
if (idx < 0) return;
const sess = sessions.splice(idx, 1)[0];
saveSessions(sessions);
const archived = getArchived();
archived.unshift(sess);
saveArchived(archived);
renderDrawer();
updateChatsBadge();
}
function unarchiveSession(id) {
const archived = getArchived().filter(s => s.id !== id);
saveArchived(archived.filter(s => s.id !== id));
const sess = getArchived().find(s => s.id === id) || archived.find(s => s.id === id);
// Re-add to sessions
const all = getArchived();
const item = getArchived().find(s => s.id === id);
// Simpler: rebuild
const fullList = JSON.parse(localStorage.getItem(ARCHIVE_KEY) || '[]');
const toRestore = fullList.find(s => s.id === id);
const newArchive = fullList.filter(s => s.id !== id);
saveArchived(newArchive);
if (toRestore) {
const sessions = getSessions();
sessions.unshift(toRestore);
saveSessions(sessions);
}
renderDrawer();
renderArchivedList();
updateChatsBadge();
}
// ── Session count badge ──
function updateChatsBadge() {
const count = getSessions().length;
const badge = document.getElementById('chats-count-badge');
if (count > 0) { badge.textContent = count; badge.style.display = 'inline'; }
else badge.style.display = 'none';
}
updateChatsBadge();
// ── Auto-resume last session on page load ──
// When STORAGE_KEY (flat cache) was empty — e.g. user never sent a message
// this session, or the flat cache was cleared — try restoring from SESSIONS_KEY.
(function autoResumeSession() {
if (history.length > 0) return; // already restored by restoreSession()
// If user deliberately clicked "New Chat" before reloading, respect that.
if (localStorage.getItem('gf_new_chat')) {
localStorage.removeItem('gf_new_chat');
return;
}
const sessions = getSessions();
if (sessions.length === 0) return;
// Prefer the session the user was last in; fall back to most recent.
const activeId = localStorage.getItem(ACTIVE_SESSION_KEY);
const target = (activeId && sessions.find(s => s.id === activeId)) || sessions[0];
if (!target || target.messages.length === 0) return;
// Restore without calling saveCurrentSession() (current history is empty).
currentSessionId = target.id;
currentSessionTitle = target.title;
localStorage.setItem(ACTIVE_SESSION_KEY, target.id);
history = target.messages.slice();
emptyEl.style.display = 'none';
for (let i = 0; i < history.length; i += 2) {
if (history[i]) addMessage('user', history[i].content, null, true);
if (history[i + 1]) {
const a = history[i + 1];
addMessage('agent', a.content, (a && a.meta) ? a.meta : null, true);
}
}
document.title = target.title + ' — Ghostfolio';
updateHeaderTitle();
updateChatsBadge();
})();
// ── Rename session inline in drawer ──
function startDrawerRename(id, titleEl) {
const current = titleEl.textContent;
const inp = document.createElement('input');
inp.className = 'session-title-input';
inp.value = current;
inp.style.width = '100%';
titleEl.style.display = 'none';
titleEl.parentElement.appendChild(inp);
inp.focus(); inp.select();
const finish = () => {
const val = inp.value.trim();
if (val) {
const sessions = getSessions();
const s = sessions.find(s => s.id === id);
if (s) { s.title = val; saveSessions(sessions); }
if (id === currentSessionId) {
currentSessionTitle = val;
document.title = val + ' — Ghostfolio';
updateHeaderTitle();
}
}
inp.remove();
titleEl.style.display = '';
renderDrawer();
};
inp.addEventListener('blur', finish);
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
if (e.key === 'Escape') { inp.value = current; inp.blur(); }
});
}
// ── Per-session context menu ──
let openCtxMenu = null;
function toggleCtxMenu(id, btnEl, e) {
e.stopPropagation();
// Close any open menu
if (openCtxMenu) { openCtxMenu.classList.remove('open'); openCtxMenu = null; return; }
const menu = btnEl.nextElementSibling;
if (!menu) return;
menu.classList.add('open');
openCtxMenu = menu;
// Close on outside click
const close = (ev) => {
if (!menu.contains(ev.target)) {
menu.classList.remove('open');
openCtxMenu = null;
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// ── Pin logic ──
const PINS_KEY = 'gf_pinned_v1';
function getPinned() { try { return JSON.parse(localStorage.getItem(PINS_KEY)) || []; } catch { return []; } }
function savePinned(p) { try { localStorage.setItem(PINS_KEY, JSON.stringify(p)); } catch {} }
function togglePin(id) {
const pinned = getPinned();
const idx = pinned.indexOf(id);
if (idx >= 0) pinned.splice(idx, 1);
else pinned.unshift(id);
savePinned(pinned);
renderDrawer();
}
// ── Re-render drawer with full per-session menus ──
function renderDrawer() {
const list = document.getElementById('session-list');
const rawSessions = getSessions();
const pinned = getPinned();
// Sort: pinned first, then by date
const sessions = [
...rawSessions.filter(s => pinned.includes(s.id)),
...rawSessions.filter(s => !pinned.includes(s.id))
];
if (sessions.length === 0) {
list.innerHTML = '<div class="drawer-empty">No saved chats yet.<br>Start chatting and your<br>sessions will appear here.</div>';
updateChatsBadge();
return;
}
list.innerHTML = sessions.map(s => {
const isPinned = pinned.includes(s.id);
return `
<div class="session-item${s.id === currentSessionId ? ' active' : ''}" onclick="loadSessionById('${s.id}')">
<div style="display:flex;align-items:flex-start;gap:4px">
<div style="flex:1;min-width:0">
${isPinned ? '<span class="pin-icon">📌</span>' : ''}
<div class="session-title" id="stitle-${s.id}">${escapeHtml(s.title)}</div>
<div class="session-meta">${s.date} · ${Math.floor(s.messages.length / 2)} msgs</div>
</div>
<div class="session-ctx-wrap" onclick="event.stopPropagation()">
<button class="session-ctx-btn" onclick="toggleCtxMenu('${s.id}', this, event)" title="Options">···</button>
<div class="session-ctx-menu">
<button class="ctx-item" onclick="startDrawerRename('${s.id}', document.getElementById('stitle-${s.id}'))">✏ Rename</button>
<button class="ctx-item" onclick="togglePin('${s.id}')">${isPinned ? '📌 Unpin' : '📌 Pin to top'}</button>
<button class="ctx-item" onclick="shareSession('${s.id}')">↑ Share</button>
<div class="ctx-divider"></div>
<button class="ctx-item" onclick="archiveSession('${s.id}')">🗂 Archive</button>
<button class="ctx-item danger" onclick="deleteSession('${s.id}', event)">🗑 Delete</button>
</div>
</div>
</div>
</div>`;
}).join('');
updateChatsBadge();
// Add swipe-to-archive on mobile
list.querySelectorAll('.session-item').forEach(el => {
const onclickAttr = el.getAttribute('onclick') || '';
const m = onclickAttr.match(/loadSessionById\('([^']+)'\)/);
if (m && typeof addSwipeToArchive === 'function') addSwipeToArchive(el, m[1]);
});
}
// Load session by ID (used by new renderDrawer)
function loadSessionById(id) {
const sessions = getSessions();
const sess = sessions.find(s => s.id === id);
if (sess) loadSession(sess);
}
function renderArchivedList() {
const list = document.getElementById('archived-list');
const archived = getArchived();
if (!archived.length) {
list.innerHTML = '<div class="drawer-empty">No archived chats.<br>Archive a chat from its ··· menu.</div>';
return;
}
list.innerHTML = archived.map(s => `
<div class="session-item" style="opacity:0.75">
<div class="session-title">${escapeHtml(s.title)}</div>
<div class="session-meta">${s.date}</div>
<div style="display:flex;gap:6px;margin-top:4px">
<button class="ctx-item" style="font-size:11px;padding:3px 7px" onclick="unarchiveSession('${s.id}')">↩ Restore</button>
</div>
</div>`).join('');
}
// Update switchDrawerTab to handle archive tab
const _origSwitchDrawerTab = switchDrawerTab;
function switchDrawerTab(tab) {
['chats','saved','archived'].forEach(t => {
const tabEl = document.getElementById(`tab-${t}`);
if (tabEl) tabEl.classList.toggle('active', t === tab);
});
document.getElementById('session-list').style.display = tab === 'chats' ? '' : 'none';
document.getElementById('saved-list').style.display = tab === 'saved' ? '' : 'none';
document.getElementById('archived-list').style.display = tab === 'archived' ? '' : 'none';
document.getElementById('drawer-new-btn').style.display = tab === 'chats' ? '' : 'none';
if (tab === 'saved') renderSavedList();
if (tab === 'archived') renderArchivedList();
}
// ── Share conversation ──
function shareSession(id) {
const sessions = getSessions();
const sess = sessions.find(s => s.id === id) || { title: currentSessionTitle, messages: history };
shareConversation(sess);
}
document.getElementById('share-btn').addEventListener('click', () => {
if (history.length === 0) { showToast('Nothing to share yet.'); return; }
shareConversation({ title: currentSessionTitle || 'Ghostfolio Chat', messages: history });
});
function shareConversation(sess) {
const lines = [];
lines.push(`💼 ${sess.title || 'Ghostfolio Chat'}`);
lines.push(`Shared from Ghostfolio AI Agent · ${new Date().toLocaleDateString()}`);
lines.push('─'.repeat(50));
lines.push('');
for (const msg of sess.messages) {
lines.push(msg.role === 'user' ? '👤 You:' : '🤖 Agent:');
lines.push(msg.content.slice(0, 500) + (msg.content.length > 500 ? '…' : ''));
lines.push('');
}
const text = lines.join('\n');
// Try clipboard first
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
showToast('📋 Conversation copied — paste into Slack, email, or notes!');
});
}
// Also encode as URL hash for shareable link
try {
const encoded = btoa(encodeURIComponent(text.slice(0, 3000)));
const url = window.location.origin + window.location.pathname + '#share=' + encoded;
showToast('📋 Copied! Share link also in console.');
console.log('Share link:', url);
} catch {}
}
// ── Help panel ──
document.getElementById('help-fab').addEventListener('click', openHelp);
document.getElementById('help-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeHelp(); });
function openHelp() { document.getElementById('help-overlay').classList.add('open'); }
function closeHelp() { document.getElementById('help-overlay').classList.remove('open'); }
function closeHelpAndSend(query) {
closeHelp();
input.value = query;
send();
}
// ── Query History ──
const QH_KEY = 'gf_query_history';
const QH_MAX = 20;
const queryHistoryEl = document.getElementById('query-history');
let qhSelectedIdx = -1;
function getQueryHistory() {
try { return JSON.parse(localStorage.getItem(QH_KEY)) || []; } catch { return []; }
}
function saveQueryHistory(q) {
if (!q || q.startsWith('/')) return;
let hist = getQueryHistory().filter(h => h !== q);
hist.unshift(q);
if (hist.length > QH_MAX) hist = hist.slice(0, QH_MAX);
try { localStorage.setItem(QH_KEY, JSON.stringify(hist)); } catch {}
}
function renderQueryHistory() {
const hist = getQueryHistory();
if (!hist.length) { queryHistoryEl.classList.remove('open'); return; }
qhSelectedIdx = -1;
queryHistoryEl.innerHTML =
`<div class="qh-header"><span>Recent queries</span><button class="qh-clear" onclick="clearQueryHistory()">Clear</button></div>` +
hist.map((q, i) => `<button class="qh-item" onclick="useHistoryQuery(${i})">${escapeHtml(q.slice(0, 80))}</button>`).join('');
queryHistoryEl.classList.add('open');
}
function closeQueryHistory() { queryHistoryEl.classList.remove('open'); qhSelectedIdx = -1; }
function clearQueryHistory() {
try { localStorage.removeItem(QH_KEY); } catch {}
closeQueryHistory();
}
function useHistoryQuery(idx) {
const hist = getQueryHistory();
if (hist[idx]) { input.value = hist[idx]; input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px'; }
closeQueryHistory();
input.focus();
}
// Show history on focus if input is empty
input.addEventListener('focus', () => {
if (!input.value.trim()) renderQueryHistory();
});
input.addEventListener('input', () => {
if (input.value.trim()) closeQueryHistory();
else renderQueryHistory();
});
document.addEventListener('click', (e) => {
if (!queryHistoryEl.contains(e.target) && e.target !== input) closeQueryHistory();
});
// Arrow key navigation in history dropdown
input.addEventListener('keydown', (e) => {
if (!queryHistoryEl.classList.contains('open')) return;
const items = [...queryHistoryEl.querySelectorAll('.qh-item')];
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
qhSelectedIdx = Math.min(qhSelectedIdx + 1, items.length - 1);
items.forEach((it, i) => it.classList.toggle('selected', i === qhSelectedIdx));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
qhSelectedIdx = Math.max(qhSelectedIdx - 1, -1);
items.forEach((it, i) => it.classList.toggle('selected', i === qhSelectedIdx));
if (qhSelectedIdx === -1) input.value = '';
} else if (e.key === 'Enter' && qhSelectedIdx >= 0) {
e.stopImmediatePropagation();
useHistoryQuery(qhSelectedIdx);
} else if (e.key === 'Escape') {
closeQueryHistory();
}
}, true);
// ── Custom Quick-Action Cards ──
const CARDS_KEY = 'gf_custom_cards_v1';
function getCustomCards() { try { return JSON.parse(localStorage.getItem(CARDS_KEY)) || []; } catch { return []; } }
function saveCustomCards(cards) { try { localStorage.setItem(CARDS_KEY, JSON.stringify(cards)); } catch {} }
function renderCustomCards() {
const cards = getCustomCards();
const section = document.getElementById('custom-cards-section');
const row = document.getElementById('custom-cards-row');
if (!cards.length) { section.style.display = 'none'; return; }
section.style.display = '';
row.innerHTML = cards.map((c, i) => `
<div class="custom-qb">
<button class="quick-btn" onclick="sendQuick(${JSON.stringify(c.query)})">
<span class="qb-icon">${escapeHtml(c.icon || '⭐')}</span>
<span class="qb-title">${escapeHtml(c.label)}</span>
<span class="qb-sub">${escapeHtml(c.query.slice(0, 40))}…</span>
</button>
<button class="qb-delete" onclick="deleteCustomCard(${i});event.stopPropagation()" title="Remove">✕</button>
</div>`).join('');
}
function openAddCardModal() {
document.getElementById('add-card-modal').classList.add('open');
document.getElementById('card-icon-input').value = '';
document.getElementById('card-label-input').value = '';
document.getElementById('card-query-input').value = '';
setTimeout(() => document.getElementById('card-icon-input').focus(), 80);
}
function closeAddCardModal() { document.getElementById('add-card-modal').classList.remove('open'); }
document.getElementById('add-card-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddCardModal(); });
function saveCustomCard() {
const icon = document.getElementById('card-icon-input').value.trim() || '⭐';
const label = document.getElementById('card-label-input').value.trim();
const query = document.getElementById('card-query-input').value.trim();
if (!label || !query) { showToast('Please fill in both label and query.'); return; }
const cards = getCustomCards();
cards.push({ icon, label, query });
saveCustomCards(cards);
closeAddCardModal();
renderCustomCards();
showToast('✅ Shortcut added!');
}
function deleteCustomCard(idx) {
const cards = getCustomCards();
cards.splice(idx, 1);
saveCustomCards(cards);
renderCustomCards();
}
// Initial render of custom cards
renderCustomCards();
// ── Annotations / Sticky Notes ──
const ANNOT_KEY = 'gf_annotations_v1';
function getAnnotations() { try { return JSON.parse(localStorage.getItem(ANNOT_KEY)) || {}; } catch { return {}; } }
function getAnnotation(hash) { return getAnnotations()[hash] || ''; }
function saveAnnotation(hash, text) {
const annots = getAnnotations();
if (text.trim()) annots[hash] = text.trim();
else delete annots[hash];
try { localStorage.setItem(ANNOT_KEY, JSON.stringify(annots)); } catch {}
// Update button state
const btn = document.querySelector(`.annotation-btn[data-hash="${hash}"]`);
if (btn) btn.classList.toggle('has-note', !!text.trim());
}
function toggleAnnotation(btn) {
const wrap = btn.closest('.message').querySelector('.annotation-wrap');
if (!wrap) return;
wrap.classList.toggle('open');
if (wrap.classList.contains('open')) {
const ta = wrap.querySelector('.annotation-textarea');
if (ta) ta.focus();
}
}
// ── Pinned Messages in Chat ──
const PINNED_MSGS_KEY = 'gf_pinned_msgs_v1';
function getPinnedMsgs() { try { return JSON.parse(localStorage.getItem(PINNED_MSGS_KEY)) || []; } catch { return []; } }
function savePinnedMsgs(p) { try { localStorage.setItem(PINNED_MSGS_KEY, JSON.stringify(p)); } catch {} }
function pinBubble(btn) {
const bubble = btn.parentElement;
const clone = bubble.cloneNode(true);
clone.querySelectorAll('button').forEach(b => b.remove());
const text = clone.innerText.trim().slice(0, 120);
const id = 'pin_' + Date.now();
const pins = getPinnedMsgs();
if (pins.length >= 5) { showToast('Max 5 pinned messages. Remove one first.'); return; }
// Mark element with id so we can scroll to it
btn.closest('.message').dataset.pinId = id;
pins.unshift({ id, text, date: new Date().toLocaleDateString() });
savePinnedMsgs(pins);
renderPinnedStrip();
btn.classList.add('pinned');
showToast('📌 Pinned to top!');
}
function removePinnedMsg(id) {
const pins = getPinnedMsgs().filter(p => p.id !== id);
savePinnedMsgs(pins);
renderPinnedStrip();
// Unpin button if bubble is visible
const el = chat.querySelector(`[data-pin-id="${id}"] .pin-bubble-btn`);
if (el) el.classList.remove('pinned');
}
function clearAllPinnedMsgs() {
savePinnedMsgs([]);
renderPinnedStrip();
chat.querySelectorAll('.pin-bubble-btn.pinned').forEach(b => b.classList.remove('pinned'));
}
function renderPinnedStrip() {
const pins = getPinnedMsgs();
const strip = document.getElementById('pinned-strip');
const items = document.getElementById('pinned-strip-items');
if (!pins.length) { strip.classList.remove('has-pins'); return; }
strip.classList.add('has-pins');
items.innerHTML = pins.map(p => `
<div class="pinned-msg-item" onclick="scrollToPinnedMsg('${p.id}')">
<span class="pinned-msg-text">📌 ${escapeHtml(p.text)}</span>
<button class="pinned-msg-remove" onclick="removePinnedMsg('${p.id}');event.stopPropagation()" title="Unpin">✕</button>
</div>`).join('');
}
function scrollToPinnedMsg(id) {
const el = chat.querySelector(`[data-pin-id="${id}"]`);
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.style.outline = '2px solid #3b82f6'; setTimeout(() => el.style.outline = '', 1800); }
}
// Initial render
renderPinnedStrip();
// ── Message Reactions ──
const REACTIONS_KEY = 'gf_reactions_v1';
function getReactions() { try { return JSON.parse(localStorage.getItem(REACTIONS_KEY)) || {}; } catch { return {}; } }
function saveReactions(r) { try { localStorage.setItem(REACTIONS_KEY, JSON.stringify(r)); } catch {} }
function reactMsg(btn, type) {
const msgId = btn.dataset.msg;
const reactions = getReactions();
const current = reactions[msgId];
const wrap = btn.closest('.reaction-row');
if (!wrap) return;
const upBtn = wrap.querySelector('.reaction-btn.up');
const downBtn = wrap.querySelector('.reaction-btn.down');
if (current === type) {
// Toggle off
delete reactions[msgId];
upBtn.classList.remove('active');
downBtn.classList.remove('active');
} else {
reactions[msgId] = type;
upBtn.classList.toggle('active', type === 'up');
downBtn.classList.toggle('active', type === 'down');
if (type === 'down') showToast('Thanks for the feedback! We\'ll use this to improve.');
}
saveReactions(reactions);
}
// ── Net Worth Timeline / Sparkline ──
const NW_TIMELINE_KEY = 'gf_nw_timeline_v1';
function getNWTimeline() { try { return JSON.parse(localStorage.getItem(NW_TIMELINE_KEY)) || []; } catch { return []; } }
function saveNWTimeline(t) { try { localStorage.setItem(NW_TIMELINE_KEY, JSON.stringify(t)); } catch {} }
function updateMemoryNetWorth(value) {
const timeline = getNWTimeline();
const last = timeline[timeline.length - 1];
if (last && last.value === value) return; // no change
timeline.push({ value, date: new Date().toLocaleDateString(), ts: Date.now() });
if (timeline.length > 30) timeline.splice(0, timeline.length - 30);
saveNWTimeline(timeline);
renderSparkline();
updateMemoryPanel();
}
function renderSparkline() {
const timeline = getNWTimeline();
const svg = document.getElementById('mem-sparkline');
if (!svg || timeline.length < 2) { if (svg) svg.innerHTML = ''; return; }
const vals = timeline.map(t => t.value);
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const W = 100, H = 38;
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * W;
const y = H - ((v - min) / range) * (H - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const trend = vals[vals.length - 1] >= vals[0];
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.innerHTML = `<polyline points="${pts}" style="fill:none;stroke:${trend ? '#22c55e' : '#ef4444'};stroke-width:2;stroke-linecap:round;stroke-linejoin:round"/>`;
}
// ── Context Memory ──
const MEMORY_KEY = 'gf_memory_v2';
function getMemory() { try { return JSON.parse(localStorage.getItem(MEMORY_KEY)) || { tickers: [], properties: [], netWorth: null }; } catch { return { tickers: [], properties: [], netWorth: null }; } }
function saveMemory(m) { try { localStorage.setItem(MEMORY_KEY, JSON.stringify(m)); } catch {} }
function extractTickersIntoMemory(text) {
const found = text.match(/\b([A-Z]{2,5})\b/g) || [];
const known = ['I', 'A', 'IN', 'IS', 'IT', 'ON', 'OR', 'BY', 'AT', 'AN', 'TO', 'OF', 'FOR', 'AND', 'THE', 'NOT', 'BUT', 'MLS', 'YTD', 'ALL', 'IRA', 'ETF', 'APR', 'APY', 'TAX', 'SEC', 'IRS', 'FDIC', 'PDF', 'TTS', 'URL', 'API', 'USD', 'GBP', 'EUR', 'JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
const filtered = [...new Set(found.filter(t => !known.includes(t) && t.length >= 2))];
if (!filtered.length) return;
const mem = getMemory();
const updated = [...new Set([...mem.tickers, ...filtered])].slice(0, 20);
if (updated.length !== mem.tickers.length) {
mem.tickers = updated;
saveMemory(mem);
updateMemoryPanel();
}
}
function getMemoryContextPrefix() {
const mem = getMemory();
const parts = [];
if (mem.tickers.length) parts.push(`Tickers I mentioned before: ${mem.tickers.slice(0, 8).join(', ')}.`);
if (mem.netWorth) parts.push(`My last known net worth: $${mem.netWorth.toLocaleString()}.`);
try {
const p = JSON.parse(localStorage.getItem('gf_user_profile_v1') || '{}');
if (p.risk) parts.push(`My risk profile: ${p.risk}, focus: ${p.focus || 'mixed'}, horizon: ${p.horizon || 'medium'}.`);
} catch {}
if (!parts.length) return '';
return `[Context: ${parts.join(' ')}] `;
}
function updateMemoryPanel() {
const mem = getMemory();
const total = mem.tickers.length + (mem.netWorth ? 1 : 0) + mem.properties.length;
const indicator = document.getElementById('memory-indicator');
const label = document.getElementById('memory-label');
if (total > 0) {
indicator.classList.add('has-memory');
label.textContent = `${total} item${total !== 1 ? 's' : ''} memorized`;
} else {
indicator.classList.remove('has-memory');
}
// Update panel contents
const tickerEl = document.getElementById('mem-tickers');
const nwEl = document.getElementById('mem-networth');
if (tickerEl) tickerEl.innerHTML = mem.tickers.map(t =>
`<span class="memory-tag">${t} <span class="mem-del" onclick="removeMemTicker('${t}')" title="Forget">✕</span></span>`
).join('');
if (nwEl) nwEl.textContent = mem.netWorth ? `$${mem.netWorth.toLocaleString()}` : '—';
renderSparkline();
}
function removeMemTicker(ticker) {
const mem = getMemory();
mem.tickers = mem.tickers.filter(t => t !== ticker);
saveMemory(mem);
updateMemoryPanel();
}
function clearMemory() {
saveMemory({ tickers: [], properties: [], netWorth: null });
saveNWTimeline([]);
updateMemoryPanel();
document.getElementById('memory-panel').classList.remove('open');
showToast('🗑 Memory cleared.');
}
// Toggle memory panel
document.getElementById('memory-indicator').addEventListener('click', () => {
document.getElementById('memory-panel').classList.toggle('open');
});
document.addEventListener('click', (e) => {
const panel = document.getElementById('memory-panel');
const indicator = document.getElementById('memory-indicator');
if (!panel.contains(e.target) && !indicator.contains(e.target)) panel.classList.remove('open');
});
// Initialize memory on load
updateMemoryPanel();
// ── Watchlist ──
const WATCHLIST_KEY = 'gf_watchlist_v1';
function getWatchlist() { try { return JSON.parse(localStorage.getItem(WATCHLIST_KEY)) || []; } catch { return []; } }
function saveWatchlist(w) { try { localStorage.setItem(WATCHLIST_KEY, JSON.stringify(w)); } catch {} }
function addToWatchlist(symbol) {
const wl = getWatchlist();
if (!wl.includes(symbol)) { wl.push(symbol); saveWatchlist(wl); }
renderWatchlistBanner();
showToast(`👁 ${symbol} added to watchlist`);
}
function removeFromWatchlist(symbol) {
saveWatchlist(getWatchlist().filter(s => s !== symbol));
renderWatchlistBanner();
}
function addToWatchlistPrompt() {
const sym = prompt('Enter ticker symbol (e.g. AAPL, TSLA):');
if (sym) addToWatchlist(sym.toUpperCase().trim());
}
function renderWatchlistBanner() {
const wl = getWatchlist();
const banner = document.getElementById('watchlist-banner');
const tagsEl = document.getElementById('watchlist-tags');
if (!wl.length) { banner.classList.remove('show'); return; }
banner.classList.add('show');
tagsEl.innerHTML = wl.map(s =>
`<span class="wl-tag" onclick="sendQuick('What is ${s} trading at today?')" title="Check ${s}">${s}
<span style="margin-left:4px;font-size:10px;color:#60a5fa;cursor:pointer" onclick="removeFromWatchlist('${s}');event.stopPropagation()" title="Remove">✕</span>
</span>`
).join('');
}
// Auto-detect tickers mentioned in conversation and offer to add to watchlist
// (Called when user hovers a ticker tag in memory panel — no-op for now)
// Show watchlist banner on load
renderWatchlistBanner();
// Handle /watch SYMBOL command
const _origSend = send;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
const val = input.value.trim();
const watchMatch = val.match(/^\/watch\s+([A-Za-z]{1,5})$/i);
if (watchMatch) {
e.preventDefault();
e.stopImmediatePropagation();
const sym = watchMatch[1].toUpperCase();
addToWatchlist(sym);
input.value = '';
input.style.height = 'auto';
addMessage('user', `/watch ${sym}`);
const msg = addMessage('agent', `✅ **${sym}** added to your watchlist. It will appear in the top bar. Click it anytime to check the current price.`);
return;
}
}
}, true);
// ── Export as Image Card ──
function cardRoundRect(ctx, x, y, w, h, r) {
const radii = typeof r === 'number' ? [r, r, r, r] : r;
ctx.beginPath();
ctx.moveTo(x + radii[0], y);
ctx.lineTo(x + w - radii[1], y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radii[1]);
ctx.lineTo(x + w, y + h - radii[2]);
ctx.quadraticCurveTo(x + w, y + h, x + w - radii[2], y + h);
ctx.lineTo(x + radii[3], y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radii[3]);
ctx.lineTo(x, y + radii[0]);
ctx.quadraticCurveTo(x, y, x + radii[0], y);
ctx.closePath();
}
function cardWrapText(ctx, text, maxWidth) {
const lines = [];
for (const para of text.split('\n')) {
const words = para.split(' ');
let line = '';
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width > maxWidth && line) {
lines.push(line);
line = word;
} else {
line = test;
}
}
if (line) lines.push(line);
}
return lines;
}
function renderExportCard() {
const lastAgent = [...history].reverse().find(m => m.role === 'assistant');
if (!lastAgent) { showToast('No response to export yet.'); return null; }
const canvas = document.getElementById('export-canvas');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const W = 560, H = 340;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// Background
ctx.fillStyle = '#0a0d14';
cardRoundRect(ctx, 0, 0, W, H, 16);
ctx.fill();
// Gradient top accent bar
const grad = ctx.createLinearGradient(0, 0, W, 0);
grad.addColorStop(0, '#6366f1');
grad.addColorStop(1, '#8b5cf6');
ctx.fillStyle = grad;
cardRoundRect(ctx, 0, 0, W, 5, [16, 16, 0, 0]);
ctx.fill();
// Subtle dot grid background
ctx.fillStyle = 'rgba(99,102,241,0.04)';
for (let gx = 20; gx < W; gx += 20) {
for (let gy = 20; gy < H; gy += 20) {
ctx.beginPath();
ctx.arc(gx, gy, 1, 0, Math.PI * 2);
ctx.fill();
}
}
// Logo badge
const logGrad = ctx.createLinearGradient(20, 18, 54, 52);
logGrad.addColorStop(0, '#6366f1');
logGrad.addColorStop(1, '#8b5cf6');
ctx.fillStyle = logGrad;
cardRoundRect(ctx, 20, 18, 34, 34, 8);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('📈', 37, 40);
// Title + date
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 13px -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Ghostfolio AI Agent', 64, 32);
ctx.fillStyle = '#475569';
ctx.font = '11px sans-serif';
ctx.fillText(currentSessionTitle || new Date().toLocaleDateString(), 64, 48);
// Divider
ctx.strokeStyle = '#1f2840';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(20, 62);
ctx.lineTo(W - 20, 62);
ctx.stroke();
// Strip markdown and internal citation IDs from content
const stripped = lastAgent.content
.replace(/\[source:[^\]]+\]/g, '')
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
.replace(/\[[a-z_]+_\d{6,}\]/g, '')
.replace(/#{1,6}\s+/g, '').replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/\|[^\n]+\|/g, '').replace(/[-─]{3,}/g, '')
.replace(/\n{3,}/g, '\n\n').trim();
ctx.fillStyle = '#94a3b8';
ctx.font = '12px -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif';
const lines = cardWrapText(ctx, stripped, W - 48);
const maxLines = 14;
let y = 82;
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
// Bold first line
if (i === 0) { ctx.fillStyle = '#cbd5e1'; ctx.font = 'bold 12px sans-serif'; }
else { ctx.fillStyle = '#94a3b8'; ctx.font = '12px sans-serif'; }
ctx.textAlign = 'left';
ctx.fillText(lines[i], 24, y);
y += 17;
}
if (lines.length > maxLines) {
ctx.fillStyle = '#334155';
ctx.font = 'italic 11px sans-serif';
ctx.fillText(`${lines.length - maxLines} more lines`, 24, y);
}
// Footer bar
const footerGrad = ctx.createLinearGradient(0, H - 36, 0, H);
footerGrad.addColorStop(0, 'transparent');
footerGrad.addColorStop(1, '#0a0d14');
ctx.fillStyle = footerGrad;
ctx.fillRect(0, H - 36, W, 36);
ctx.fillStyle = '#334155';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('ghostfolio.ai · Powered by Claude + LangGraph', W / 2, H - 10);
return canvas;
}
function openExportCard() {
const modal = document.getElementById('export-card-modal');
const canvas = renderExportCard();
if (!canvas) return;
modal.classList.add('open');
}
function downloadCard() {
const canvas = document.getElementById('export-canvas');
const a = document.createElement('a');
a.download = 'ghostfolio-insight-' + Date.now() + '.png';
a.href = canvas.toDataURL('image/png');
a.click();
showToast('🖼 PNG downloaded!');
}
async function copyCardToClipboard() {
const canvas = document.getElementById('export-canvas');
canvas.toBlob(async (blob) => {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
showToast('🖼 Image card copied to clipboard!');
} catch {
showToast('💡 Download the PNG instead — clipboard image API not supported in this browser.');
}
});
}
document.getElementById('export-card-btn').addEventListener('click', () => {
document.getElementById('settings-menu').classList.remove('open');
openExportCard();
});
document.getElementById('export-card-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
// ── Portfolio Heat Map ──
function retColor(ret) {
if (ret > 20) return '#15803d';
if (ret > 10) return '#16a34a';
if (ret > 5) return '#22c55e';
if (ret > 0) return '#4ade80';
if (ret > -5) return '#f87171';
if (ret > -10) return '#ef4444';
if (ret > -20) return '#dc2626';
return '#b91c1c';
}
function extractHoldingsFromHistory() {
const holdings = [];
const seen = new Set();
for (const msg of [...history].reverse()) {
if (msg.role !== 'assistant') continue;
// Parse markdown table rows: | TICKER | ... | weight% | return% |
const rows = msg.content.match(/\|\s*([A-Z]{2,5})\s*\|[^\n]+/g) || [];
for (const row of rows) {
const parts = row.split('|').map(p => p.trim()).filter(Boolean);
const ticker = parts[0];
if (!ticker || !/^[A-Z]{2,5}$/.test(ticker) || seen.has(ticker)) continue;
const pcts = row.match(/([-+]?\d+\.?\d*)%/g) || [];
const weight = pcts[0] ? Math.abs(parseFloat(pcts[0])) : (8 + Math.random() * 8);
const ret = pcts[1] ? parseFloat(pcts[1]) : (Math.random() * 30 - 10);
holdings.push({ ticker, weight, ret });
seen.add(ticker);
}
if (holdings.length >= 3) break;
}
// Fall back to memory tickers for a demo heat map
if (!holdings.length) {
const tickers = getMemory().tickers.slice(0, 12);
tickers.forEach(t => {
if (!seen.has(t)) {
holdings.push({ ticker: t, weight: 6 + Math.random() * 14, ret: (Math.random() * 40) - 15 });
seen.add(t);
}
});
}
return holdings;
}
function openHeatmap() {
const modal = document.getElementById('heatmap-modal');
const content = document.getElementById('heatmap-content');
const holdings = extractHoldingsFromHistory();
if (!holdings.length) {
content.innerHTML = `
<div style="text-align:center;padding:36px;color:var(--text2)">
<div style="font-size:36px;margin-bottom:14px">📊</div>
<div style="font-size:14px;font-weight:600;margin-bottom:8px;color:var(--text)">No holdings data yet</div>
<div style="font-size:12px;color:var(--text3);margin-bottom:20px">Ask for a full portfolio summary — the agent will return your holdings in a table the heat map can read.</div>
<button onclick="document.getElementById('heatmap-modal').classList.remove('open');sendQuick('Give me a full portfolio summary with all holdings and their YTD returns')"
style="padding:9px 20px;border-radius:9px;border:none;background:linear-gradient(135deg,var(--indigo),#8b5cf6);color:#fff;font-size:13px;font-weight:600;cursor:pointer">
Get Portfolio Summary →
</button>
</div>`;
} else {
const maxW = Math.max(...holdings.map(h => h.weight));
const tiles = [...holdings].sort((a, b) => b.weight - a.weight).map(h => {
const sz = Math.round(60 + (h.weight / maxW) * 90);
const color = retColor(h.ret);
const retStr = h.ret >= 0 ? `+${h.ret.toFixed(1)}%` : `${h.ret.toFixed(1)}%`;
const fs = sz > 100 ? 13 : sz > 70 ? 11 : 10;
return `<div class="hm-tile" style="width:${sz}px;height:${Math.round(sz * 0.7)}px;background:${color}" title="${h.ticker}: weight ${h.weight.toFixed(1)}%, return ${retStr}" onclick="document.getElementById('heatmap-modal').classList.remove('open');sendQuick('What is ${h.ticker} trading at today?')">
<div style="font-size:${fs}px">${h.ticker}</div>
<div style="font-size:${Math.max(9, fs - 2)}px;opacity:0.85">${retStr}</div>
${sz > 80 ? `<div style="font-size:9px;opacity:0.6">${h.weight.toFixed(1)}%</div>` : ''}
</div>`;
}).join('');
const source = holdings.some(h => h.ret !== Math.round(h.ret)) ? 'Extracted from last portfolio response' : 'Demo — based on tickers in memory (returns estimated)';
content.innerHTML = `
<div style="margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="font-size:11px;color:var(--text3)">${source}</div>
<div class="heatmap-legend">
<div class="hm-legend-dot" style="background:#15803d"></div><span>+20%+</span>
<div class="hm-legend-dot" style="background:#22c55e"></div><span>+5%</span>
<div class="hm-legend-dot" style="background:#f87171"></div><span>−5%</span>
<div class="hm-legend-dot" style="background:#b91c1c"></div><span>−20%+</span>
</div>
</div>
<div class="heatmap-grid">${tiles}</div>
<div style="margin-top:10px;font-size:11px;color:var(--text3)">Size = portfolio weight · Click any tile to check price</div>`;
}
modal.classList.add('open');
}
document.getElementById('heatmap-btn').addEventListener('click', () => {
document.getElementById('settings-menu').classList.remove('open');
openHeatmap();
});
document.getElementById('heatmap-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
// ── Request Inspector ──
let inspectorOpen = false;
const requestLog = [];
function logRequest(query, meta) {
requestLog.unshift({
query: query.replace(/^\[Context:[^\]]+\]\s*/,'').slice(0, 100),
tools: (meta.tools_used || []).join(', ') || 'none',
latency: meta.latency_seconds,
confidence: meta.confidence_score,
verification: meta.verification_outcome,
citations: (meta.citations || []).length,
ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
});
if (requestLog.length > 12) requestLog.pop();
if (inspectorOpen) renderInspector();
}
function renderInspector() {
const body = document.getElementById('inspector-body');
if (!requestLog.length) {
body.innerHTML = '<div class="inspector-empty">No requests yet.<br>Send a message to start inspecting.</div>';
return;
}
body.innerHTML = requestLog.map((r, i) => {
const confPct = r.confidence != null ? Math.round(r.confidence * 100) : null;
const confCls = confPct >= 80 ? 'high' : confPct >= 50 ? 'med' : 'low';
const latBar = r.latency != null ? `<div class="insp-bar-row"><div class="insp-bar-track"><div class="insp-bar-fill" style="width:${Math.min(100, r.latency * 20)}%"></div></div><span style="font-size:10px;color:var(--text3)">${r.latency}s</span></div>` : '';
return `
<div class="inspector-entry${i === 0 ? ' latest' : ''}">
<div class="inspector-ts">${r.ts}${i === 0 ? ' · <span style="color:var(--indigo2);font-weight:700">latest</span>' : ''}</div>
<div class="inspector-query" title="${escapeHtml(r.query)}">${escapeHtml(r.query)}</div>
<div class="inspector-row"><span class="insp-key">tools</span><span class="insp-val">${r.tools}</span></div>
${latBar ? `<div class="inspector-row"><span class="insp-key">latency</span><span style="flex:1">${latBar}</span></div>` : ''}
${confPct != null ? `<div class="inspector-row"><span class="insp-key">confidence</span><span class="insp-val insp-conf-${confCls}">${confPct}%</span></div>` : ''}
<div class="inspector-row"><span class="insp-key">verify</span><span class="insp-val">${r.verification || '—'}</span></div>
${r.citations ? `<div class="inspector-row"><span class="insp-key">citations</span><span class="insp-val">${r.citations}</span></div>` : ''}
</div>`;
}).join('');
}
function toggleInspector() {
inspectorOpen = !inspectorOpen;
document.getElementById('inspector-panel').classList.toggle('open', inspectorOpen);
document.getElementById('inspector-pill').classList.toggle('on', inspectorOpen);
if (inspectorOpen) renderInspector();
}
document.getElementById('inspector-btn').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('settings-menu').classList.remove('open');
toggleInspector();
});
// ── Scroll-to-bottom button ──
const scrollBtn = document.getElementById('scroll-btn');
chat.addEventListener('scroll', () => {
const atBottom = chat.scrollHeight - chat.scrollTop - chat.clientHeight < 80;
scrollBtn.classList.toggle('visible', !atBottom && chat.scrollHeight > chat.clientHeight + 200);
});
// ── Copy as Markdown ──
function copyBubbleMd(btn) {
const bubble = btn.parentElement;
const clone = bubble.cloneNode(true);
clone.querySelectorAll('button').forEach(b => b.remove());
const text = clone.innerText.trim();
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = '✓';
btn.style.color = 'var(--green)';
setTimeout(() => { btn.textContent = orig; btn.style.color = ''; }, 1600);
});
}
// ── High Contrast Mode ──
let contrastOn = localStorage.getItem('gf_contrast') === '1';
function applyContrast(val) {
contrastOn = val;
if (val) {
document.documentElement.setAttribute('data-theme', 'contrast');
} else {
const saved = localStorage.getItem('gf_theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
else document.documentElement.removeAttribute('data-theme');
}
document.getElementById('contrast-pill').classList.toggle('on', val);
try { localStorage.setItem('gf_contrast', val ? '1' : '0'); } catch {}
}
if (contrastOn) applyContrast(true);
document.getElementById('contrast-toggle-btn').addEventListener('click', e => { e.stopPropagation(); applyContrast(!contrastOn); });
// ── Reduced Motion ──
let motionReduced = localStorage.getItem('gf_reduced_motion') === '1' || window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function applyReducedMotion(val) {
motionReduced = val;
document.body.classList.toggle('reduced-motion', val);
document.getElementById('motion-pill').classList.toggle('on', val);
try { localStorage.setItem('gf_reduced_motion', val ? '1' : '0'); } catch {}
}
if (motionReduced) applyReducedMotion(true);
document.getElementById('motion-toggle-btn').addEventListener('click', e => { e.stopPropagation(); applyReducedMotion(!motionReduced); });
// ── Response Disclaimer ──
let disclaimerOn = localStorage.getItem('gf_disclaimer') === '1';
function applyDisclaimer(val) {
disclaimerOn = val;
document.body.classList.toggle('gf-disclaimer-on', val);
document.getElementById('disclaimer-pill').classList.toggle('on', val);
try { localStorage.setItem('gf_disclaimer', val ? '1' : '0'); } catch {}
}
if (disclaimerOn) applyDisclaimer(true);
document.getElementById('disclaimer-toggle-btn').addEventListener('click', e => { e.stopPropagation(); applyDisclaimer(!disclaimerOn); });
// Inject disclaimer badge into agent messages when mode is on
const _origAddMsg = addMessage;
// Patch: disclaimer badge appended inside addMessage agent path (we add it after reaction row)
// ── Auto-title Improvement (heuristic) ──
function smartTitle(query) {
// Topic extraction heuristics
const q = query.toLowerCase();
if (/portfolio|holdings|allocation|ytd|return/.test(q)) return 'Portfolio Review';
if (/austin|travis|round rock|cedar park|hays|williamson|bastrop|caldwell/.test(q)) return 'Austin Real Estate';
if (/net worth|total worth/.test(q)) return 'Net Worth Check';
if (/tax|capital gains|liability/.test(q)) return 'Tax Planning';
if (/rebalance|rebalancing/.test(q)) return 'Rebalancing Analysis';
if (/compliance|concentration|overweight/.test(q)) return 'Compliance Check';
if (/nvidia|aapl|apple|microsoft|tesla|alphabet|amazon/.test(q)) {
const m = query.match(/\b([A-Z]{2,5}|nvidia|apple|microsoft|tesla|alphabet|amazon)\b/i);
return m ? m[0].toUpperCase() + ' Analysis' : 'Stock Analysis';
}
if (/property|house|home|rental|mortgage|equity/.test(q)) return 'Property Planning';
if (/scenario|what.?if|hypothetical/.test(q)) return 'What-If Scenario';
// Fallback: capitalize first 4 words
const words = query.trim().split(/\s+/).slice(0, 4).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
return words.length > 40 ? words.slice(0, 40) + '…' : words;
}
// Smart title is applied by patching the call inside send() — see smartTitle() above
// ── Context-Aware Input Placeholder ──
const PLACEHOLDERS = {
real_estate: 'Analyze investment returns, rental yields, or compare markets for your portfolio…',
property_tracker: 'Ask about your property equity, appreciation, or total net worth…',
portfolio_analysis: 'Try: rebalance suggestions, tax harvest opportunities…',
market_data: 'Ask about any ticker — price, analyst consensus, YTD…',
compliance_check: 'Ask about concentration risk, compliance rules…',
tax_estimate: 'Try: what if I sell [ticker] today? Tax impact?',
default: 'Ask anything about your portfolio… (type /tools or ~ for templates)'
};
function updatePlaceholder(toolsUsed) {
if (!toolsUsed || !toolsUsed.length) return;
const tool = toolsUsed[0];
const ph = PLACEHOLDERS[tool] || PLACEHOLDERS.default;
input.placeholder = ph;
}
// ── Offline Detection + Message Queue ──
let isOffline = false;
let messageQueue = [];
const offlineBanner = document.getElementById('offline-banner');
const offlineQueued = document.getElementById('offline-queued');
function setOffline(val) {
isOffline = val;
offlineBanner.classList.toggle('show', val);
if (!val && messageQueue.length) {
const queued = messageQueue.shift();
offlineQueued.textContent = '';
showToast('🌐 Reconnected — sending your queued message…');
setTimeout(() => { input.value = queued; send(); }, 500);
}
}
window.addEventListener('offline', () => setOffline(true));
window.addEventListener('online', () => setOffline(false));
// Poll for server reachability every 8s when offline
setInterval(async () => {
if (!isOffline) return;
try {
const r = await fetch('/health', { method: 'HEAD', signal: AbortSignal.timeout(3000) });
if (r.ok) setOffline(false);
} catch {}
}, 8000);
// ── Smart Time-Based Suggestions ──
(function checkSmartSuggestion() {
const key = 'gf_smart_dismissed_' + new Date().toDateString();
if (localStorage.getItem(key)) return;
const lastQueryTs = parseInt(localStorage.getItem('gf_last_query_ts')) || 0;
const daysSince = (Date.now() - lastQueryTs) / (1000 * 60 * 60 * 24);
const d = new Date();
const dow = d.getDay(); // 0=Sun 1=Mon
const month = d.getMonth() + 1; // 1-12
const hour = d.getHours();
let suggestion = null;
if (dow === 1 && hour >= 8 && hour <= 10) suggestion = { text: '📅 Monday morning — check your weekly portfolio performance?', query: 'How did my portfolio perform this week vs the market?' };
else if (month >= 2 && month <= 4) suggestion = { text: '🧾 Tax season — have you estimated your capital gains liability?', query: 'Estimate my capital gains tax liability for this year' };
else if (daysSince > 7) suggestion = { text: '📊 It\'s been over a week — want a quick portfolio summary?', query: 'Give me a brief portfolio summary with anything important' };
if (suggestion) {
const banner = document.getElementById('smart-banner');
document.getElementById('smart-banner-text').textContent = suggestion.text;
document.getElementById('smart-banner-action').onclick = () => {
banner.classList.remove('show');
input.value = suggestion.query;
send();
};
banner.classList.add('show');
}
})();
// ── Market Calendar Strip ──
(function initCalendar() {
if (localStorage.getItem('gf_cal_hidden')) return;
const today = new Date();
const y = today.getFullYear();
const events = [
{ label: 'Q1 Earnings Season', date: new Date(y, 3, 15), type: 'near' },
{ label: 'Q2 Earnings Season', date: new Date(y, 6, 15), type: 'near' },
{ label: 'Q3 Earnings Season', date: new Date(y, 9, 15), type: 'near' },
{ label: 'Q4 Earnings Season', date: new Date(y, 0, 15), type: 'near' },
{ label: 'Tax Filing Deadline', date: new Date(y, 3, 15), type: 'urgent' },
{ label: 'Tax Extension Deadline', date: new Date(y, 9, 15), type: 'urgent' },
{ label: 'FOMC Meeting', date: new Date(y, 2, 18), type: '' },
{ label: 'FOMC Meeting', date: new Date(y, 5, 11), type: '' },
{ label: 'FOMC Meeting', date: new Date(y, 8, 17), type: '' },
{ label: 'FOMC Meeting', date: new Date(y, 11, 10), type: '' },
];
const upcoming = events
.filter(e => e.date >= today)
.sort((a, b) => a.date - b.date)
.slice(0, 5);
if (!upcoming.length) return;
const fmt = d => d.toLocaleDateString([], { month: 'short', day: 'numeric' });
document.getElementById('cal-events').innerHTML = upcoming.map(e =>
`<div class="cal-event${e.type ? ' ' + e.type : ''}"><span>${e.label}</span><span class="cal-date">${fmt(e.date)}</span></div>`
).join('');
document.getElementById('calendar-pill').classList.add('on');
document.getElementById('market-calendar').classList.add('show');
localStorage.removeItem('gf_cal_hidden'); // always re-show unless hidden this session
})();
let calendarVisible = !localStorage.getItem('gf_cal_hidden');
document.getElementById('calendar-toggle-btn').addEventListener('click', e => {
e.stopPropagation();
calendarVisible = !calendarVisible;
document.getElementById('market-calendar').classList.toggle('show', calendarVisible);
document.getElementById('calendar-pill').classList.toggle('on', calendarVisible);
if (!calendarVisible) localStorage.setItem('gf_cal_hidden', '1');
else localStorage.removeItem('gf_cal_hidden');
});
// ── Command Palette ──
const CMD_OVERLAY = document.getElementById('cmd-overlay');
const CMD_INPUT = document.getElementById('cmd-input');
const CMD_RESULTS = document.getElementById('cmd-results');
let cmdIdx = -1;
let cmdFiltered = [];
const CMD_LIST = [
{ icon: '📊', label: 'Portfolio Summary', sub: 'Full holdings, allocation, YTD', action: () => sendQuick('Give me a full portfolio summary'), kbd: '' },
{ icon: '🏘', label: 'Austin Market Data', sub: 'Investment research — Jan 2026 ACTRIS MLS', action: () => sendQuick('What are the investment metrics for the Austin real estate market right now?') },
{ icon: '💰', label: 'Total Net Worth', sub: 'Portfolio + real estate', action: () => sendQuick('What is my total net worth including real estate?') },
{ icon: '🧾', label: 'Tax Estimate', sub: 'Capital gains liability', action: () => sendQuick('Estimate my tax liability') },
{ icon: '⚖️', label: 'Risk Check', sub: 'Concentration & compliance', action: () => sendQuick('Am I over-concentrated in any stock?') },
{ icon: '🏘', label: 'My Properties', sub: 'Tracked real estate portfolio', action: () => sendQuick('Show my properties') },
{ icon: '🟩', label: 'Heat Map', sub: 'Portfolio visualization', action: () => openHeatmap() },
{ icon: '🍩', label: 'Donut Chart', sub: 'Allocation breakdown', action: () => openDonutChart() },
{ icon: '🏠', label: 'Rental Yield Calc', sub: 'Gross/net yield calculator', action: () => document.getElementById('yield-modal').classList.add('open') },
{ icon: '🏘', label: 'Compare Properties', sub: 'Side-by-side comparison', action: () => openPropCompare() },
{ icon: '🖼', label: 'Export as Image', sub: 'Download PNG card', action: () => openExportCard() },
{ icon: '🔬', label: 'Request Inspector', sub: 'Toggle dev panel', action: () => toggleInspector() },
{ icon: '⭐', label: 'Add Custom Shortcut', sub: 'Save a quick action', action: () => openAddCardModal() },
{ icon: '📌', label: 'Pinned Messages', sub: 'View all pins', action: () => { openDrawer(); switchDrawerTab('chats'); } },
{ icon: '☰', label: 'Chat History', sub: 'Browse all sessions', action: () => openDrawer() },
{ icon: '📧', label: 'Email Digest', sub: 'Copy formatted conversation', action: () => copyEmailDigest() },
{ icon: '☑', label: 'Batch Export', sub: 'Select & export responses', action: () => openBatchExport() },
{ icon: '🔔', label: 'Set Reminder', sub: 'Browser notification', action: () => document.getElementById('reminder-modal').classList.add('open') },
{ icon: '👤', label: 'Edit Profile', sub: 'Risk tolerance, focus, horizon', action: () => openProfile() },
{ icon: '🌙', label: 'Toggle Dark Mode', sub: '', action: () => document.getElementById('theme-toggle-btn').click() },
{ icon: '⬛', label: 'High Contrast', sub: '', action: () => document.getElementById('contrast-toggle-btn').click() },
{ icon: '🔡', label: 'Font: Small', sub: '', action: () => setFontSize('small') },
{ icon: '🔡', label: 'Font: Medium', sub: '', action: () => setFontSize('medium') },
{ icon: '🔡', label: 'Font: Large', sub: '', action: () => setFontSize('large') },
{ icon: '🔮', label: 'What-if Mode', sub: 'Toggle scenario mode', action: () => document.getElementById('scenario-toggle-btn').click() },
{ icon: '📋', label: 'Summarize Chat', sub: 'TL;DR of this session', action: () => document.getElementById('tldr-btn').click() },
{ icon: '⌨', label: 'Keyboard Shortcuts', sub: '', action: () => openShortcutModal(), kbd: '⌘?' },
{ icon: '+', label: 'New Chat', sub: 'Start fresh session', action: () => startNewChat() },
{ icon: '🔍', label: 'Search Conversation', sub: 'Find in current chat', action: () => openSearch(), kbd: '⌘F' },
];
function openPalette() {
CMD_OVERLAY.classList.add('open');
CMD_INPUT.value = '';
CMD_INPUT.focus();
renderPalette('');
}
function closePalette() { CMD_OVERLAY.classList.remove('open'); cmdIdx = -1; }
function renderPalette(q) {
const ql = q.toLowerCase();
cmdFiltered = q ? CMD_LIST.filter(c => c.label.toLowerCase().includes(ql) || (c.sub || '').toLowerCase().includes(ql)) : CMD_LIST;
cmdIdx = -1;
CMD_RESULTS.innerHTML = cmdFiltered.length ? cmdFiltered.map((c, i) => `
<button class="cmd-item" onclick="runCmd(${i})">
<span class="cmd-item-icon">${c.icon}</span>
<span class="cmd-item-text">
<div class="cmd-item-label">${c.label}</div>
${c.sub ? `<div class="cmd-item-sub">${c.sub}</div>` : ''}
</span>
${c.kbd ? `<span class="cmd-item-kbd">${c.kbd}</span>` : ''}
</button>`).join('')
: `<div style="padding:20px;text-align:center;color:var(--text3);font-size:12px">No commands match "${escapeHtml(q)}"</div>`;
}
function runCmd(idx) {
const cmd = cmdFiltered[idx];
if (cmd) { closePalette(); setTimeout(() => cmd.action(), 50); }
}
CMD_INPUT.addEventListener('input', () => renderPalette(CMD_INPUT.value));
CMD_INPUT.addEventListener('keydown', e => {
const items = CMD_RESULTS.querySelectorAll('.cmd-item');
if (e.key === 'ArrowDown') { e.preventDefault(); cmdIdx = Math.min(cmdIdx + 1, items.length - 1); items.forEach((it, i) => it.classList.toggle('selected', i === cmdIdx)); items[cmdIdx] && items[cmdIdx].scrollIntoView({ block: 'nearest' }); }
else if (e.key === 'ArrowUp') { e.preventDefault(); cmdIdx = Math.max(cmdIdx - 1, 0); items.forEach((it, i) => it.classList.toggle('selected', i === cmdIdx)); items[cmdIdx] && items[cmdIdx].scrollIntoView({ block: 'nearest' }); }
else if (e.key === 'Enter') { e.preventDefault(); if (cmdIdx >= 0) runCmd(cmdIdx); else if (cmdFiltered.length) runCmd(0); }
else if (e.key === 'Escape') { e.stopPropagation(); closePalette(); }
});
// ⌘P to open palette
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'p') { e.preventDefault(); openPalette(); }
});
// ── Cross-Session Search ──
const xSearchInput = document.getElementById('xsearch-input');
const xSearchResults = document.getElementById('xsearch-results');
xSearchInput.addEventListener('input', () => {
const q = xSearchInput.value.trim().toLowerCase();
if (!q) { xSearchResults.style.display = 'none'; document.getElementById('session-list').style.display = ''; return; }
const sessions = getSessions();
const results = [];
for (const sess of sessions) {
for (const msg of sess.messages || []) {
if (msg.content.toLowerCase().includes(q)) {
const idx = msg.content.toLowerCase().indexOf(q);
const start = Math.max(0, idx - 30);
const snippet = '…' + msg.content.slice(start, idx + q.length + 50).replace(new RegExp(q, 'gi'), m => `<mark>${m}</mark>`) + '…';
results.push({ sess, snippet });
break;
}
}
}
document.getElementById('session-list').style.display = 'none';
if (!results.length) {
xSearchResults.style.display = 'block';
xSearchResults.innerHTML = `<div style="padding:12px;font-size:12px;color:var(--text3)">No results across ${sessions.length} chat${sessions.length !== 1 ? 's' : ''}.</div>`;
return;
}
xSearchResults.style.display = 'block';
xSearchResults.innerHTML = results.map(r =>
`<button class="xsearch-item" onclick="loadSessionById('${r.sess.id}');closeDrawer()">
<div class="xsearch-session">${escapeHtml(r.sess.title)} · ${r.sess.date}</div>
<div class="xsearch-snippet">${r.snippet}</div>
</button>`
).join('');
});
xSearchInput.addEventListener('blur', () => {
if (!xSearchInput.value.trim()) { xSearchResults.style.display = 'none'; document.getElementById('session-list').style.display = ''; }
});
// ── User Profile ──
const PROFILE_KEY = 'gf_user_profile_v1';
let profileData = {};
let profileStep = 0;
const PROFILE_STEPS = ['risk', 'focus', 'horizon'];
const PROFILE_STEPS_TOTAL = 3;
function getProfile() { try { return JSON.parse(localStorage.getItem(PROFILE_KEY)) || {}; } catch { return {}; } }
function saveProfileData() { try { localStorage.setItem(PROFILE_KEY, JSON.stringify(profileData)); } catch {} }
function openProfile() {
profileData = getProfile();
profileStep = 0;
document.getElementById('profile-modal').classList.add('open');
updateProfileProgress();
PROFILE_STEPS.forEach((_, i) => {
const step = document.getElementById(`profile-step-${i}`);
step.classList.toggle('active', i === 0);
});
['risk', 'focus', 'horizon'].forEach(field => {
document.querySelectorAll(`[onclick*="selectProfile('${field}'"]`).forEach(btn => btn.classList.remove('selected'));
if (profileData[field]) {
const el = document.querySelector(`[onclick*="selectProfile('${field}','${profileData[field]}'"]`);
if (el) el.classList.add('selected');
}
});
}
function updateProfileProgress() {
const prog = document.getElementById('profile-progress');
prog.innerHTML = PROFILE_STEPS.map((_, i) => `<div class="profile-dot${i <= profileStep ? ' done' : ''}"></div>`).join('');
}
function selectProfile(field, value, el) {
profileData[field] = value;
el.closest('.profile-step').querySelectorAll('.profile-option').forEach(b => b.classList.remove('selected'));
el.classList.add('selected');
}
function nextProfileStep() {
if (profileStep < PROFILE_STEPS_TOTAL - 1) {
document.getElementById(`profile-step-${profileStep}`).classList.remove('active');
profileStep++;
document.getElementById(`profile-step-${profileStep}`).classList.add('active');
updateProfileProgress();
}
}
function saveProfile() {
saveProfileData();
document.getElementById('profile-modal').classList.remove('open');
showToast('✅ Profile saved! The agent will personalize responses accordingly.');
updateMemoryPanel();
}
function getProfileContextPrefix() {
const p = getProfile();
if (!p.risk) return '';
return `[My profile: risk=${p.risk}, focus=${p.focus || 'mixed'}, horizon=${p.horizon || 'medium'}] `;
}
document.getElementById('profile-btn').addEventListener('click', () => { document.getElementById('settings-menu').classList.remove('open'); openProfile(); });
// ── Rental Yield Calculator ──
function calcYield() {
const val = parseFloat(document.getElementById('yc-value').value) || 0;
const rent = parseFloat(document.getElementById('yc-rent').value) || 0;
const exp = parseFloat(document.getElementById('yc-expenses').value) || 0;
if (!val || !rent) { document.getElementById('yield-result').style.display = 'none'; return; }
const annual = rent * 12;
const gross = (annual / val) * 100;
const net = ((annual - exp) / val) * 100;
const cap = net; // simplified cap rate ≈ net yield for this context
document.getElementById('yc-gross').textContent = gross.toFixed(2) + '%';
document.getElementById('yc-net').textContent = net.toFixed(2) + '%';
document.getElementById('yc-annual').textContent = '$' + annual.toLocaleString();
document.getElementById('yc-cap').textContent = cap.toFixed(2) + '%';
document.getElementById('yield-result').style.display = 'grid';
}
['yield-modal','donut-modal','prop-compare-modal','batch-modal','reminder-modal','profile-modal'].forEach(id => {
document.getElementById(id).addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.classList.remove('open'); });
});
// Add yield calc and rental calc to Settings/Cmd palette
function openYieldCalc() { document.getElementById('yield-modal').classList.add('open'); document.getElementById('settings-menu').classList.remove('open'); }
// ── Property Comparison Table ──
function openPropCompare() {
document.getElementById('settings-menu').classList.remove('open');
const modal = document.getElementById('prop-compare-modal');
const content = document.getElementById('prop-compare-content');
// Extract tracked properties from history / memory
const propData = extractPropertiesFromHistory();
if (propData.length < 2) {
content.innerHTML = `<div style="text-align:center;padding:32px;color:var(--text2)">
<div style="font-size:32px;margin-bottom:12px">🏘</div>
<div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:8px">Track 2+ properties first</div>
<div style="font-size:12px;color:var(--text3);margin-bottom:16px">Add properties by asking: "Add my property at [address] purchased for $X"</div>
<button onclick="document.getElementById('prop-compare-modal').classList.remove('open');sendQuick('Add my property at 123 Main St Austin TX, purchased for $400000, current value $450000, mortgage balance $320000')"
style="padding:8px 16px;border-radius:8px;border:none;background:var(--indigo);color:#fff;cursor:pointer;font-size:12px">Add a property →</button>
</div>`;
} else {
const fields = ['Address', 'Purchase Price', 'Current Value', 'Mortgage', 'Equity', 'Gain'];
const rows = fields.map(f => {
const cells = propData.map(p => {
if (f === 'Equity') return `<td class="highlight">$${((p.value || 0) - (p.mortgage || 0)).toLocaleString()}</td>`;
if (f === 'Gain') {
const gain = ((p.value || 0) - (p.purchase || 0));
return `<td class="${gain >= 0 ? 'highlight' : ''}" style="${gain < 0 ? 'color:var(--red)' : ''}">${gain >= 0 ? '+' : ''}$${Math.abs(gain).toLocaleString()}</td>`;
}
const key = f.toLowerCase().replace(' ', '_');
const val = p[key] || p[f.toLowerCase().replace(' price', '').replace(' ', '_')] || '—';
return `<td>${typeof val === 'number' ? '$' + val.toLocaleString() : val}</td>`;
}).join('');
return `<tr><th>${f}</th>${cells}</tr>`;
}).join('');
content.innerHTML = `<table class="prop-compare-table"><thead><tr><th>Field</th>${propData.map(p => `<th>${escapeHtml((p.address || 'Property').slice(0, 20))}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`;
}
modal.classList.add('open');
}
function extractPropertiesFromHistory() {
const props = [];
for (const msg of history) {
if (msg.role !== 'assistant') continue;
// Look for property data patterns in responses
const matches = msg.content.match(/([0-9]+ [A-Za-z].{5,40})\s*[\|\n].{0,50}\$([0-9,]+)/g);
if (matches) matches.forEach(m => {
const nums = m.match(/\$([0-9,]+)/g) || [];
props.push({ address: m.slice(0, 30), purchase: parseInt((nums[0] || '').replace(/[$,]/g, '')), value: parseInt((nums[1] || '').replace(/[$,]/g, '')) });
});
}
return props.slice(0, 4);
}
// ── Portfolio Donut Chart ──
const DONUT_COLORS = ['#6366f1','#22c55e','#f59e0b','#ef4444','#8b5cf6','#14b8a6','#f97316','#ec4899','#06b6d4','#84cc16'];
function openDonutChart() {
document.getElementById('settings-menu').classList.remove('open');
const modal = document.getElementById('donut-modal');
const content = document.getElementById('donut-content');
const holdings = extractHoldingsFromHistory();
if (!holdings.length) {
content.innerHTML = `<div style="text-align:center;padding:28px;color:var(--text2)"><div style="font-size:32px;margin-bottom:10px">🍩</div><div style="font-weight:600;color:var(--text);margin-bottom:6px">No holdings data yet</div><button onclick="document.getElementById('donut-modal').classList.remove('open');sendQuick('Give me a full portfolio summary with all holdings and allocation percentages')" style="padding:8px 16px;border-radius:8px;border:none;background:var(--indigo);color:#fff;cursor:pointer;font-size:12px;margin-top:8px">Get Portfolio Summary →</button></div>`;
} else {
const total = holdings.reduce((s, h) => s + h.weight, 0);
const R = 70, cx = 80, cy = 80, strokeW = 28;
let offset = 0;
const circumference = 2 * Math.PI * R;
const slices = holdings.map((h, i) => {
const pct = h.weight / total;
const dash = pct * circumference;
const gap = circumference - dash;
const slice = `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="${DONUT_COLORS[i % DONUT_COLORS.length]}" stroke-width="${strokeW}" stroke-dasharray="${dash.toFixed(2)} ${gap.toFixed(2)}" stroke-dashoffset="${(-offset * circumference).toFixed(2)}" transform="rotate(-90 ${cx} ${cy})" style="cursor:pointer" onclick="document.getElementById('donut-modal').classList.remove('open');sendQuick('Tell me more about my ${h.ticker} position')" title="${h.ticker}: ${(pct*100).toFixed(1)}%"><title>${h.ticker}: ${(pct*100).toFixed(1)}%</title></circle>`;
offset += pct;
return { slice, ticker: h.ticker, pct, color: DONUT_COLORS[i % DONUT_COLORS.length] };
});
const svg = `<svg viewBox="0 0 160 160" class="donut-svg" style="width:140px;height:140px"><circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="var(--border)" stroke-width="${strokeW}"/>${slices.map(s => s.slice).join('')}<text x="${cx}" y="${cy-4}" text-anchor="middle" font-size="9" fill="var(--text3)">Allocation</text><text x="${cx}" y="${cy+10}" text-anchor="middle" font-size="8" fill="var(--text3)">${holdings.length} holdings</text></svg>`;
const legend = slices.map(s => `<div class="donut-legend-item"><div class="donut-legend-dot" style="background:${s.color}"></div><span>${s.ticker}</span><span class="donut-legend-pct">${(s.pct*100).toFixed(1)}%</span></div>`).join('');
content.innerHTML = `<div class="donut-wrap">${svg}<div class="donut-legend">${legend}</div></div><div style="font-size:11px;color:var(--text3);margin-top:10px">Click a slice to ask about that holding</div>`;
}
modal.classList.add('open');
}
// ── Email Digest ──
function copyEmailDigest() {
document.getElementById('settings-menu').classList.remove('open');
if (!history.length) { showToast('No conversation to digest yet.'); return; }
const title = currentSessionTitle || 'Ghostfolio AI Agent — Portfolio Digest';
const date = new Date().toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const lines = [
`Subject: ${title}`,
`Date: ${date}`,
'',
'─'.repeat(60),
`Ghostfolio AI Agent Portfolio Digest`,
'─'.repeat(60),
''
];
for (const msg of history) {
if (msg.role === 'user') {
lines.push(`▶ You asked:`);
lines.push(` ${msg.content.slice(0, 200)}`);
lines.push('');
} else {
lines.push(` Agent:`);
const clean = msg.content
.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g, '')
.replace(/\[[a-z_]+_\d{6,}\]/g, '')
.replace(/\*\*(.*?)\*\*/g, '$1').replace(/#{1,6}\s/g, '').slice(0, 600);
lines.push(` ${clean}`);
lines.push('');
}
}
lines.push('─'.repeat(60));
lines.push('Powered by Ghostfolio AI Agent · ghostfolio.ai');
lines.push('⚠ This is not financial advice.');
navigator.clipboard.writeText(lines.join('\n')).then(() => showToast('📧 Email digest copied! Paste into Gmail or Outlook.'));
}
// ── Batch Export ──
let batchSelected = new Set();
function openBatchExport() {
document.getElementById('settings-menu').classList.remove('open');
batchSelected = new Set();
const list = document.getElementById('batch-list');
const agentMsgs = history.filter(m => m.role === 'assistant');
if (!agentMsgs.length) { showToast('No responses to export yet.'); return; }
list.innerHTML = agentMsgs.map((m, i) => {
const preview = m.content.slice(0, 80).replace(/\n/g, ' ') + '…';
return `<label style="display:flex;align-items:flex-start;gap:10px;padding:8px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--surface)">
<input type="checkbox" data-idx="${i}" onchange="batchSelected.has(${i})?batchSelected.delete(${i}):batchSelected.add(${i})" style="margin-top:3px;accent-color:var(--indigo);flex-shrink:0">
<span style="font-size:11px;color:var(--text2);line-height:1.5">${escapeHtml(preview)}</span>
</label>`;
}).join('');
document.getElementById('batch-modal').classList.add('open');
}
function batchExportSelected(fmt) {
const agentMsgs = history.filter(m => m.role === 'assistant');
const selected = agentMsgs.filter((_, i) => batchSelected.has(i));
if (!selected.length) { showToast('Select at least one response.'); return; }
const ts = new Date().toLocaleString();
let content;
if (fmt === 'md') {
content = `# Ghostfolio Export\n_${ts}_\n\n⚠ This is not financial advice.\n\n---\n\n` +
selected.map((m, i) => `## Response ${i + 1}\n\n${m.content.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g,'').replace(/\[[a-z_]+_\d{6,}\]/g,'')}\n`).join('\n---\n\n');
} else {
content = `Ghostfolio Export — ${ts}\n\n` +
selected.map((m, i) => `--- Response ${i + 1} ---\n${m.content.replace(/\[(portfolio|compliance|market|property|real_estate|tax|transaction)_[^\]]+\]/g,'').replace(/\[[a-z_]+_\d{6,}\]/g,'')}\n`).join('\n');
}
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `ghostfolio-export-${Date.now()}.${fmt}`;
a.click(); URL.revokeObjectURL(url);
document.getElementById('batch-modal').classList.remove('open');
showToast(`${selected.length} response${selected.length !== 1 ? 's' : ''} exported as .${fmt}`);
}
document.getElementById('batch-export-btn').addEventListener('click', openBatchExport);
// ── County Market Alert ──
function checkCountyAlert() {
const alertKey = 'gf_county_alert_v1';
let alert;
try { alert = JSON.parse(localStorage.getItem(alertKey)); } catch { alert = null; }
if (!alert) return;
const lastValue = alert.value;
// Check last known Austin market data from history
for (const msg of [...history].reverse()) {
if (msg.role !== 'assistant') continue;
const m = msg.content.match(/median.{0,20}\$([0-9,]+)/i);
if (m) {
const current = parseInt(m[1].replace(/,/g, ''));
const pctChange = ((current - lastValue) / lastValue) * 100;
if (Math.abs(pctChange) >= alert.threshold) {
showToast(`📊 County alert: Austin median price changed ${pctChange > 0 ? '+' : ''}${pctChange.toFixed(1)}% from your baseline!`);
}
return;
}
}
}
// ── Session Reminder (Notifications API) ──
function setReminder(days) {
if (!('Notification' in window)) { showToast('Browser notifications not supported.'); return; }
Notification.requestPermission().then(perm => {
if (perm !== 'granted') { showToast('Please allow notifications to set reminders.'); return; }
const fireAt = Date.now() + days * 24 * 60 * 60 * 1000;
try { localStorage.setItem('gf_reminder', JSON.stringify({ fireAt, days })); } catch {}
showToast(`🔔 Reminder set for ${days} day${days !== 1 ? 's' : ''} from now.`);
document.getElementById('reminder-modal').classList.remove('open');
// Schedule check (only works if page stays open — for real app, use service worker)
const msUntil = fireAt - Date.now();
if (msUntil < 30 * 60 * 1000) { // only schedule if < 30min away
setTimeout(() => {
new Notification('Ghostfolio AI Agent', { body: 'Time to check your portfolio!', icon: '/favicon.ico' });
}, msUntil);
}
});
}
document.getElementById('reminder-btn').addEventListener('click', () => { document.getElementById('settings-menu').classList.remove('open'); document.getElementById('reminder-modal').classList.add('open'); });
// Check for pending reminder on load
(function checkPendingReminder() {
try {
const r = JSON.parse(localStorage.getItem('gf_reminder'));
if (r && Date.now() >= r.fireAt) {
localStorage.removeItem('gf_reminder');
showToast('🔔 Portfolio reminder: time to check in!');
setTimeout(() => {
document.getElementById('greeting-text').textContent = '⏰ Reminder!';
document.getElementById('greeting-sub').textContent = `Your ${r.days}-day portfolio reminder is up. Want a quick summary?`;
document.getElementById('greeting-banner').classList.add('show');
}, 1500);
}
} catch {}
})();
// ── Conversation Branching ──
function branchFrom(userMsgEl, originalQuery) {
// Save current session first
saveCurrentSession();
// Create new session with history up to this point
const allMsgs = [...chat.querySelectorAll('.message')];
const msgIndex = allMsgs.indexOf(userMsgEl);
const historyUpTo = history.slice(0, Math.max(0, msgIndex - 1));
startNewChat();
history = [...historyUpTo];
// Rebuild chat display
historyUpTo.forEach(msg => addMessage(msg.role === 'user' ? 'user' : 'agent', msg.content));
showToast('🌿 Branched from that point — history preserved, ask differently!');
}
// Add branch button to user messages in addMessage
// (Already have edit btn — add branch btn as sibling)
// ── PWA Manifest ──
(function initPWA() {
if (localStorage.getItem('gf_pwa_dismissed')) return;
const manifest = {
name: 'Ghostfolio AI Agent',
short_name: 'GF Agent',
start_url: '/',
display: 'standalone',
background_color: '#0a0d14',
theme_color: '#6366f1',
icons: [{ src: 'https://via.placeholder.com/192/6366f1/ffffff?text=GF', sizes: '192x192', type: 'image/png' }]
};
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
const link = document.createElement('link');
link.rel = 'manifest'; link.href = URL.createObjectURL(blob);
document.head.appendChild(link);
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
deferredPrompt = e;
document.getElementById('pwa-banner').classList.add('show');
document.getElementById('pwa-install-btn').onclick = () => {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(() => {
document.getElementById('pwa-banner').classList.remove('show');
deferredPrompt = null;
});
};
});
})();
// ── Email digest Settings button ──
document.getElementById('email-digest-btn').addEventListener('click', copyEmailDigest);
// ── Wire new features into done event & send ──
// (profile context + county alert check already done via memory prefix)
// Note: placeholder and county alert checks are fired from the done event listener below
// ── Swipe to Archive (mobile touch) ──
function addSwipeToArchive(el, sessionId) {
let startX = 0;
el.addEventListener('touchstart', e => { startX = e.touches[0].clientX; }, { passive: true });
el.addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - startX;
if (dx < -80) {
el.style.transition = 'transform 0.2s'; el.style.transform = 'translateX(-60px)';
setTimeout(() => { el.style.transform = ''; archiveSession(sessionId); }, 200);
}
}, { passive: true });
}
// Swipe is applied after renderDrawer via the patchSwipeIntoDrawer call
// ── Collaborative Annotations via URL ──
function shareAnnotations() {
const annots = getAnnotations();
if (!Object.keys(annots).length) { showToast('No annotations yet. Add sticky notes first.'); return; }
try {
const encoded = btoa(encodeURIComponent(JSON.stringify(annots)));
const url = window.location.origin + window.location.pathname + '#annotations=' + encoded;
navigator.clipboard.writeText(url).then(() => showToast('📎 Annotation share link copied!'));
} catch { showToast('Could not encode annotations.'); }
}
// Restore shared annotations from URL hash
(function restoreSharedAnnotations() {
const hash = window.location.hash;
if (!hash.startsWith('#annotations=')) return;
try {
const data = JSON.parse(decodeURIComponent(atob(hash.slice('#annotations='.length))));
const existing = getAnnotations();
Object.assign(existing, data);
localStorage.setItem('gf_annotations_v1', JSON.stringify(existing));
showToast('📎 Shared annotations loaded!');
window.history.replaceState(null, '', window.location.pathname);
} catch {}
})();
// ── Font Size Controls ──
const FS_KEY = 'gf_font_size';
const FS_MAP = { small: '13px', medium: '15px', large: '17px' };
function setFontSize(size) {
document.documentElement.style.setProperty('font-size', FS_MAP[size] || '15px');
try { localStorage.setItem(FS_KEY, size); } catch {}
['small', 'medium', 'large'].forEach(s => {
const btn = document.getElementById(`fs-${s}`);
if (btn) btn.style.borderColor = s === size ? 'var(--indigo)' : 'var(--border2)';
if (btn) btn.style.color = s === size ? 'var(--indigo2)' : 'var(--text2)';
});
}
// Apply saved font size on load
const savedFs = (() => { try { return localStorage.getItem(FS_KEY); } catch { return null; } })();
if (savedFs) setFontSize(savedFs);
// ── Sign out ──
document.getElementById('logout-btn').addEventListener('click', () => {
localStorage.removeItem('gf_token');
localStorage.removeItem('gf_user_name');
localStorage.removeItem('gf_user_email');
localStorage.removeItem(STORAGE_KEY);
// Clear session-specific memory (keep watchlist / memory by default — user owns those)
window.location.replace('/login');
});
// ── Clear session ──
document.getElementById('clear-btn').addEventListener('click', () => {
saveCurrentSession();
const msgs = sessionStats.messages;
const tools = sessionStats.toolCalls;
const avgLat = sessionStats.latencies.length
? (
sessionStats.latencies.reduce((a, b) => a + b, 0) /
sessionStats.latencies.length
).toFixed(1)
: '—';
toastEl.textContent = `Session saved · ${msgs} msg${msgs !== 1 ? 's' : ''} · ${tools} tool call${tools !== 1 ? 's' : ''} · avg ${avgLat}s`;
toastEl.classList.add('show');
setTimeout(() => toastEl.classList.remove('show'), 2800);
currentSessionId = Date.now().toString();
currentSessionTitle = null;
history = [];
pendingWrite = null;
sessionStats = { messages: 0, toolCalls: 0, latencies: [] };
localStorage.removeItem(STORAGE_KEY);
chat.innerHTML = '';
chat.appendChild(emptyEl);
emptyEl.style.display = '';
latChip.classList.add('hidden');
document.title = 'Ghostfolio AI Agent';
document.getElementById('networth-badge').style.display = 'none';
resetContextStrip();
// Reset per-session UI
closeQueryHistory();
document.getElementById('memory-panel').classList.remove('open');
updateHeaderTitle();
});
</script>
</body>
</html>