feat: Add Context Settings Modal with Terminal Preview and UI Enhancements (#161)

* feat: Add Context Injection Settings modal with terminal preview

Adds a new settings modal accessible from the viewer UI header that allows users to configure context injection parameters with a live terminal preview showing how observations will appear.

Changes:
- New ContextSettingsModal component with auto-saving settings
- TerminalPreview component for live context visualization
- useContextPreview hook for fetching preview data
- Modal positioned to left of color mode button
- Settings sync with backend via worker service API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Add demo data and modify contextHook for cm_demo_content project

- Introduced DEMO_OBSERVATIONS and DEMO_SUMMARIES for the cm_demo_content project to provide mock data for testing and demonstration purposes.
- Updated contextHook to utilize demo data when the project is cm_demo_content, filtering observations based on configured types and concepts.
- Adjusted the worker service to use the contextHook with demo data, ensuring ANSI rendering for terminal output.
- Enhanced error handling and ensured proper closure of database connections.

* feat: add GitHub stars button with dynamic star count

- Implemented a new GitHubStarsButton component that fetches and displays the star count for a specified GitHub repository.
- Added useGitHubStars hook to handle API requests and state management for star count.
- Created formatStarCount utility function to format the star count into compact notation (k/M suffixes).
- Styled the GitHub stars button to match existing UI components, including hover and active states.
- Updated Header component to include the new GitHubStarsButton, replacing the static GitHub link.
- Added responsive styles to hide the GitHub stars button on mobile devices.

* feat: add API endpoint to fetch distinct projects and update context settings modal

- Implemented a new API endpoint `/api/projects` in `worker-service.ts` to retrieve a list of distinct projects from the observations.
- Modified `ContextSettingsModal.tsx` to replace the current project display with a dropdown for selecting projects, utilizing the fetched project list.
- Updated `useContextPreview.ts` to fetch projects on mount and manage the selected project state.
- Removed the `currentProject` prop from `ContextSettingsModal` and `App` components as it is now managed internally within the modal.

* Enhance Context Settings Modal and Terminal Preview

- Updated the styling of the Context Settings Modal for a modern clean design, including improved backdrop, header, and body layout.
- Introduced responsive design adjustments for smaller screens.
- Added custom scrollbar styles for better user experience.
- Refactored the TerminalPreview component to utilize `ansi-to-html` for rendering ANSI content, improving text display.
- Implemented new font variables for terminal styling across the application.
- Enhanced checkbox and input styles in the settings panel for better usability and aesthetics.
- Improved the layout and structure of settings groups and chips for a more organized appearance.

* Refactor UI components for compact design and enhance MCP toggle functionality

- Updated grid layout in viewer.html and viewer-template.html for better space utilization.
- Reduced padding and font sizes in settings groups, filter chips, and form controls for a more compact appearance.
- Implemented MCP toggle state management in ContextSettingsModal with API integration for status fetching and toggling.
- Reorganized settings groups for clarity, renaming and consolidating sections for improved user experience.
- Added feedback mechanism for MCP toggle status to inform users of changes and errors.

* feat: add collapsible sections, chip groups, form fields with tooltips, and toggle switches in settings modal

- Implemented collapsible sections for better organization of settings.
- Added chip groups with select all/none functionality for observation types and concepts.
- Enhanced form fields with optional tooltips for better user guidance.
- Introduced toggle switches for various settings, improving user interaction.
- Updated styles for new components to ensure consistency and responsiveness.
- Refactored ContextSettingsModal to utilize new components and improve readability.
- Improved TerminalPreview component styling for better layout and usability.

* Refactor modal header and preview selector styles; enhance terminal preview functionality

- Updated modal header padding and added gap for better spacing.
- Introduced a new header-controls section to include a project preview selector.
- Enhanced the preview selector styles for improved usability and aesthetics.
- Adjusted the preview column styles for a cleaner look.
- Implemented word wrap toggle functionality in the TerminalPreview component, allowing users to switch between wrapped and scrollable text.
- Improved scroll position handling in TerminalPreview to maintain user experience during content updates.

* feat: enhance modal settings with new icon links and update header controls

- Added new modal icon links for documentation and social media in ContextSettingsModal.
- Updated the header to remove sidebar toggle functionality and replaced it with context preview toggle.
- Refactored styles for modal icon links to improve UI/UX.
- Removed sidebar component from App and adjusted related state management.

* chore: remove abandoned cm_demo_content demo data approach

The demo data feature was prototyped but didn't work out. Removes:
- DEMO_OBSERVATIONS and DEMO_SUMMARIES arrays
- Conditional logic that bypassed DB for demo project
- Demo mode check in prior message extraction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2025-12-03 21:34:41 -05:00
committed by GitHub
parent c78500cac2
commit 375dd1c3d6
17 changed files with 2937 additions and 179 deletions
+25
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.27",
"@modelcontextprotocol/sdk": "^1.20.1",
"ansi-to-html": "^0.7.2",
"better-sqlite3": "^11.0.0",
"express": "^4.18.2",
"glob": "^11.0.3",
@@ -1614,6 +1615,21 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansi-to-html": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
"license": "MIT",
"dependencies": {
"entities": "^2.2.0"
},
"bin": {
"ansi-to-html": "bin/ansi-to-html"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ansis": {
"version": "4.0.0-node10",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.0.0-node10.tgz",
@@ -2147,6 +2163,15 @@
"node": ">=8.6"
}
},
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+1
View File
@@ -48,6 +48,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.27",
"@modelcontextprotocol/sdk": "^1.20.1",
"ansi-to-html": "^0.7.2",
"better-sqlite3": "^11.0.0",
"express": "^4.18.2",
"glob": "^11.0.3",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+906
View File
@@ -80,6 +80,9 @@
--color-skeleton-highlight: #e8ecef;
--shadow-focus: 0 0 0 2px rgba(9, 105, 218, 0.3);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
/* Theme Variables - Dark Mode */
@@ -146,6 +149,9 @@
--color-skeleton-highlight: #4a4540;
--shadow-focus: 0 0 0 2px rgba(88, 166, 255, 0.2);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
/* System preference default */
@@ -213,6 +219,9 @@
--color-skeleton-highlight: #e8ecef;
--shadow-focus: 0 0 0 2px rgba(9, 105, 218, 0.3);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
}
@@ -280,6 +289,9 @@
--color-skeleton-highlight: #505050;
--shadow-focus: 0 0 0 2px rgba(88, 166, 255, 0.2);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
}
@@ -602,6 +614,61 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
/* GitHub Stars Button - Similar to Community Button */
.github-stars-btn {
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
border-radius: 6px;
padding: 0 14px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.github-stars-btn:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.github-stars-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
/* Stars count animation */
.stars-count {
animation: countUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
}
.stars-loading {
opacity: 0.5;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes countUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.icon-link {
display: flex;
align-items: center;
@@ -1453,6 +1520,11 @@
.community-btn {
display: none;
}
/* Hide GitHub stars button on mobile */
.github-stars-btn {
display: none;
}
}
/* Mobile Responsive Styles - 480px and below */
@@ -1590,6 +1662,840 @@
height: 44px;
}
}
/* Context Settings Modal - Modern Clean Design */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
padding: 20px;
}
.context-settings-modal {
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: 12px;
width: 100%;
max-width: 1200px;
height: 90vh;
max-height: 800px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.modal-header {
padding: 14px 20px;
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-bg-header);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.modal-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-header);
letter-spacing: -0.01em;
flex-shrink: 0;
}
.header-controls {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
justify-content: flex-end;
}
.preview-selector {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
}
.preview-selector select {
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-selector select:hover {
border-color: var(--color-border-focus);
background: var(--color-bg-card-hover);
}
.preview-selector select:focus {
outline: none;
border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modal-close-btn {
background: transparent;
border: 1px solid var(--color-border-primary);
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
}
.modal-close-btn:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: scale(1.05);
}
.modal-close-btn:active {
transform: scale(0.95);
}
.modal-icon-link {
background: transparent;
border: 1px solid var(--color-border-primary);
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
text-decoration: none;
}
.modal-icon-link:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: scale(1.05);
}
.modal-icon-link:active {
transform: scale(0.95);
}
.modal-body {
flex: 1;
display: grid;
grid-template-columns: 70fr 30fr;
gap: 0;
overflow: hidden;
min-height: 0;
}
/* Preview Column - Terminal Style */
.preview-column {
padding: 20px;
overflow: hidden;
border-right: none;
background: transparent;
display: flex;
flex-direction: column;
}
.preview-column-header {
padding: 16px 20px;
background: #141414;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.preview-column-header label {
display: block;
font-size: 11px;
font-weight: 600;
color: #888;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-column-header select {
width: 100%;
background: #0a0a0a;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 8px 12px;
height: 36px;
font-size: 13px;
font-weight: 500;
color: #ddd;
cursor: pointer;
transition: all 0.2s;
}
.preview-column-header select:hover {
border-color: rgba(255, 255, 255, 0.2);
background: #111;
}
.preview-column-header select:focus {
outline: none;
border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}
.preview-content {
flex: 1;
overflow-y: auto;
padding: 20px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: #ccc;
}
.preview-content pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Settings Column */
.settings-column {
padding: 0;
overflow-y: auto;
background: var(--color-bg-primary);
position: relative;
}
/* Custom Scrollbar */
.settings-column::-webkit-scrollbar {
width: 8px;
}
.settings-column::-webkit-scrollbar-track {
background: transparent;
}
.settings-column::-webkit-scrollbar-thumb {
background: var(--color-bg-scrollbar-thumb);
border-radius: 4px;
}
.settings-column::-webkit-scrollbar-thumb:hover {
background: var(--color-bg-scrollbar-thumb-hover);
}
.preview-content::-webkit-scrollbar {
width: 8px;
}
.preview-content::-webkit-scrollbar-track {
background: transparent;
}
.preview-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
.preview-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* Settings Groups - Compact */
.settings-group {
padding: 14px 16px;
border-bottom: 1px solid var(--color-border-primary);
}
.settings-group:last-child {
border-bottom: none;
}
.settings-group h4 {
margin: 0 0 10px 0;
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
}
/* Filter Chips - Compact */
.chips-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
min-height: 28px;
border: 1px solid var(--color-border-primary);
border-radius: 4px;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
background: var(--color-bg-card);
cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.chip:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-hover);
color: var(--color-text-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.chip:active {
transform: translateY(0);
}
.chip.selected {
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
color: white;
border-color: var(--color-bg-button);
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.25);
}
.chip.selected:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(9, 105, 218, 0.35);
}
/* Form Controls in Modal - Compact */
.settings-group input[type="number"],
.settings-group select {
width: 100%;
background: var(--color-bg-input);
border: 1px solid var(--color-border-primary);
border-radius: 4px;
padding: 6px 10px;
height: 32px;
font-size: 12px;
color: var(--color-text-primary);
transition: all 0.2s;
margin-top: 4px;
}
.settings-group input[type="number"]:hover,
.settings-group select:hover {
border-color: var(--color-border-hover);
}
.settings-group input[type="number"]:focus,
.settings-group select:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
.settings-group label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 4px;
}
/* Checkboxes - Compact */
.settings-group input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
margin-right: 6px;
accent-color: var(--color-accent-primary);
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.checkbox-item {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 0;
}
.checkbox-item label {
margin: 0;
cursor: pointer;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
}
.checkbox-item:hover label {
color: var(--color-text-primary);
}
/* Number Input Group - Compact */
.number-input-group {
margin-top: 6px;
}
.select-group {
margin-top: 6px;
}
.number-input-group + .number-input-group,
.select-group + .number-input-group,
.number-input-group + .select-group {
margin-top: 10px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ============================================
NEW: Collapsible Sections
============================================ */
.settings-section-collapsible {
border-bottom: 1px solid var(--color-border-primary);
}
.settings-section-collapsible:last-child {
border-bottom: none;
}
.section-header-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.section-header-btn:hover {
background: var(--color-bg-card-hover);
}
.section-header-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
letter-spacing: -0.01em;
}
.section-description {
font-size: 11px;
color: var(--color-text-muted);
font-weight: 400;
}
.chevron-icon {
color: var(--color-text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevron-icon.rotated {
transform: rotate(180deg);
}
.section-content {
padding: 0 16px 16px 16px;
}
/* ============================================
NEW: Chip Groups with All/None
============================================ */
.chip-group {
margin-bottom: 14px;
}
.chip-group:last-child {
margin-bottom: 0;
}
.chip-group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.chip-group-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chip-group-actions {
display: flex;
gap: 4px;
}
.chip-action {
padding: 2px 8px;
font-size: 10px;
font-weight: 500;
color: var(--color-text-muted);
background: transparent;
border: 1px solid var(--color-border-primary);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.chip-action:hover {
color: var(--color-text-primary);
border-color: var(--color-border-hover);
background: var(--color-bg-card-hover);
}
.chip-action.active {
color: var(--color-accent-primary);
border-color: var(--color-accent-primary);
background: var(--color-type-badge-bg);
}
/* ============================================
NEW: Form Fields with Tooltips
============================================ */
.form-field {
margin-bottom: 12px;
}
.form-field:last-child {
margin-bottom: 0;
}
.form-field-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 6px;
}
.tooltip-trigger {
display: inline-flex;
align-items: center;
color: var(--color-text-muted);
cursor: help;
transition: color 0.15s ease;
}
.tooltip-trigger:hover {
color: var(--color-accent-primary);
}
.form-field input[type="number"],
.form-field select {
width: 100%;
background: var(--color-bg-input);
border: 1px solid var(--color-border-primary);
border-radius: 6px;
padding: 8px 12px;
height: 36px;
font-size: 13px;
color: var(--color-text-primary);
transition: all 0.15s ease;
}
.form-field input[type="number"]:hover,
.form-field select:hover {
border-color: var(--color-border-hover);
}
.form-field input[type="number"]:focus,
.form-field select:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
/* ============================================
NEW: Toggle Switches
============================================ */
.toggle-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--color-border-secondary);
}
.toggle-row:last-child {
border-bottom: none;
}
.toggle-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.toggle-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-primary);
cursor: pointer;
}
.toggle-description {
font-size: 11px;
color: var(--color-text-muted);
line-height: 1.3;
}
.toggle-switch {
position: relative;
width: 40px;
height: 22px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: 11px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
margin-left: 12px;
padding: 0;
}
.toggle-switch:hover:not(.disabled) {
border-color: var(--color-border-hover);
}
.toggle-switch.on {
background: var(--color-accent-primary);
border-color: var(--color-accent-primary);
}
.toggle-switch.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-switch.on .toggle-knob {
transform: translateX(18px);
}
/* ============================================
NEW: Display Subsections
============================================ */
.display-subsection {
padding: 12px 0;
border-bottom: 1px solid var(--color-border-secondary);
}
.display-subsection:first-child {
padding-top: 0;
}
.display-subsection:last-child {
border-bottom: none;
padding-bottom: 0;
}
.subsection-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
/* ============================================
Improved Chip Styles
============================================ */
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
min-height: 30px;
border: 1px solid var(--color-border-primary);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
background: var(--color-bg-card);
cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.chip:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-accent-primary);
color: var(--color-text-primary);
}
.chip:active {
transform: scale(0.98);
}
.chip.selected {
background: var(--color-accent-primary);
color: white;
border-color: var(--color-accent-primary);
}
.chip.selected:hover {
background: var(--color-bg-button-hover);
border-color: var(--color-bg-button-hover);
}
/* Responsive Modal */
@media (max-width: 900px) {
.modal-body {
grid-template-columns: 1fr;
}
.preview-column {
display: none;
}
}
@media (max-width: 600px) {
.modal-backdrop {
padding: 0;
}
.context-settings-modal {
border-radius: 0;
height: 100vh;
max-height: none;
}
.modal-header {
padding: 12px 16px;
gap: 12px;
}
.preview-selector {
font-size: 11px;
gap: 6px;
}
.preview-selector select {
padding: 5px 10px;
font-size: 11px;
}
.settings-group {
padding: 14px 16px;
}
.section-header-btn {
padding: 12px 14px;
}
.section-content {
padding: 0 14px 14px 14px;
}
.toggle-row {
padding: 8px 0;
}
.toggle-switch {
width: 36px;
height: 20px;
}
.toggle-knob {
width: 14px;
height: 14px;
}
.toggle-switch.on .toggle-knob {
transform: translateX(16px);
}
}
</style>
</head>
+7 -4
View File
@@ -289,7 +289,7 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
const cwd = input?.cwd ?? process.cwd();
const project = cwd ? path.basename(cwd) : 'unknown-project';
let db: SessionStore;
let db: SessionStore | null = null;
try {
db = new SessionStore();
} catch (error: any) {
@@ -393,7 +393,7 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
// If we have neither observations nor summaries, show empty state
if (observations.length === 0 && recentSummaries.length === 0) {
db.close();
db?.close();
if (useColors) {
return `\n${colors.bright}${colors.cyan}📝 [${project}] recent context${colors.reset}\n${colors.gray}${'─'.repeat(60)}${colors.reset}\n\n${colors.dim}No previous sessions found for this project yet.${colors.reset}\n`;
}
@@ -623,7 +623,7 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
// Render observation
const obs = item.data;
const files = parseJsonArray(obs.files_modified);
const file = files.length > 0 ? toRelativePath(files[0], cwd) : 'General';
const file = (files.length > 0 && files[0]) ? toRelativePath(files[0], cwd) : 'General';
// Check if we need a new file section
if (file !== currentFile) {
@@ -793,7 +793,7 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
}
}
db.close();
db?.close();
// Add debug info directly to output
// if (debugInfo.length > 0) {
@@ -806,6 +806,9 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
return output.join('\n').trimEnd();
}
// Export for use by worker service
export { contextHook };
// Entry Point - handle stdin/stdout
const forceColors = process.argv.includes('--colors');
+69
View File
@@ -169,6 +169,7 @@ export class WorkerService {
this.app.get('/api/prompt/:id', this.handleGetPromptById.bind(this));
this.app.get('/api/stats', this.handleGetStats.bind(this));
this.app.get('/api/projects', this.handleGetProjects.bind(this));
this.app.get('/api/processing-status', this.handleGetProcessingStatus.bind(this));
this.app.post('/api/processing', this.handleSetProcessing.bind(this));
@@ -202,6 +203,7 @@ export class WorkerService {
this.app.get('/api/search/by-type', this.handleSearchByType.bind(this));
this.app.get('/api/context/recent', this.handleGetRecentContext.bind(this));
this.app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this));
this.app.get('/api/context/preview', this.handleContextPreview.bind(this));
this.app.get('/api/timeline/by-query', this.handleGetTimelineByQuery.bind(this));
this.app.get('/api/search/help', this.handleSearchHelp.bind(this));
}
@@ -818,6 +820,31 @@ export class WorkerService {
}
}
/**
* Get list of distinct projects from observations
* GET /api/projects
*/
private handleGetProjects(req: Request, res: Response): void {
try {
const db = this.dbManager.getSessionStore().db;
const rows = db.prepare(`
SELECT DISTINCT project
FROM observations
WHERE project IS NOT NULL
GROUP BY project
ORDER BY MAX(created_at_epoch) DESC
`).all() as Array<{ project: string }>;
const projects = rows.map(row => row.project);
res.json({ projects });
} catch (error) {
logger.failure('WORKER', 'Get projects failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Validate context settings from request body
*/
@@ -1482,6 +1509,48 @@ export class WorkerService {
}
}
/**
* Generate context preview for settings modal
* GET /api/context/preview?project=...
*/
private async handleContextPreview(req: Request, res: Response): Promise<void> {
try {
// Dynamic import to use BUILT context-hook function
const packageRoot = getPackageRoot();
const contextHookPath = path.join(packageRoot, 'plugin', 'scripts', 'context-hook.js');
const { contextHook } = await import(contextHookPath);
// Get project from query parameter
const projectName = req.query.project as string;
if (!projectName) {
return res.status(400).json({ error: 'Project parameter is required' });
}
// Use project name as CWD (contextHook uses path.basename to get project)
const cwd = `/preview/${projectName}`;
// Generate preview context (with colors for terminal display)
const contextText = await contextHook(
{
session_id: 'preview-' + Date.now(),
cwd: cwd
},
true // useColors=true for ANSI terminal output
);
// Return as plain text
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(contextText);
} catch (error) {
logger.failure('WORKER', 'Context preview generation failed', {}, error as Error);
res.status(500).json({
error: 'Failed to generate context preview',
message: (error as Error).message
});
}
}
/**
* Get timeline by query (search first, then get timeline around best match)
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
+906
View File
@@ -80,6 +80,9 @@
--color-skeleton-highlight: #e8ecef;
--shadow-focus: 0 0 0 2px rgba(9, 105, 218, 0.3);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
/* Theme Variables - Dark Mode */
@@ -146,6 +149,9 @@
--color-skeleton-highlight: #4a4540;
--shadow-focus: 0 0 0 2px rgba(88, 166, 255, 0.2);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
/* System preference default */
@@ -213,6 +219,9 @@
--color-skeleton-highlight: #e8ecef;
--shadow-focus: 0 0 0 2px rgba(9, 105, 218, 0.3);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
}
@@ -280,6 +289,9 @@
--color-skeleton-highlight: #505050;
--shadow-focus: 0 0 0 2px rgba(88, 166, 255, 0.2);
/* Font families */
--font-terminal: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace;
}
}
@@ -602,6 +614,61 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
/* GitHub Stars Button - Similar to Community Button */
.github-stars-btn {
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
border-radius: 6px;
padding: 0 14px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.github-stars-btn:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.github-stars-btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
/* Stars count animation */
.stars-count {
animation: countUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
}
.stars-loading {
opacity: 0.5;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes countUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.icon-link {
display: flex;
align-items: center;
@@ -1453,6 +1520,11 @@
.community-btn {
display: none;
}
/* Hide GitHub stars button on mobile */
.github-stars-btn {
display: none;
}
}
/* Mobile Responsive Styles - 480px and below */
@@ -1590,6 +1662,840 @@
height: 44px;
}
}
/* Context Settings Modal - Modern Clean Design */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
padding: 20px;
}
.context-settings-modal {
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: 12px;
width: 100%;
max-width: 1200px;
height: 90vh;
max-height: 800px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.modal-header {
padding: 14px 20px;
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-bg-header);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
}
.modal-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-header);
letter-spacing: -0.01em;
flex-shrink: 0;
}
.header-controls {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
justify-content: flex-end;
}
.preview-selector {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
}
.preview-selector select {
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-selector select:hover {
border-color: var(--color-border-focus);
background: var(--color-bg-card-hover);
}
.preview-selector select:focus {
outline: none;
border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modal-close-btn {
background: transparent;
border: 1px solid var(--color-border-primary);
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
}
.modal-close-btn:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: scale(1.05);
}
.modal-close-btn:active {
transform: scale(0.95);
}
.modal-icon-link {
background: transparent;
border: 1px solid var(--color-border-primary);
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
text-decoration: none;
}
.modal-icon-link:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: scale(1.05);
}
.modal-icon-link:active {
transform: scale(0.95);
}
.modal-body {
flex: 1;
display: grid;
grid-template-columns: 70fr 30fr;
gap: 0;
overflow: hidden;
min-height: 0;
}
/* Preview Column - Terminal Style */
.preview-column {
padding: 20px;
overflow: hidden;
border-right: none;
background: transparent;
display: flex;
flex-direction: column;
}
.preview-column-header {
padding: 16px 20px;
background: #141414;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.preview-column-header label {
display: block;
font-size: 11px;
font-weight: 600;
color: #888;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-column-header select {
width: 100%;
background: #0a0a0a;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 8px 12px;
height: 36px;
font-size: 13px;
font-weight: 500;
color: #ddd;
cursor: pointer;
transition: all 0.2s;
}
.preview-column-header select:hover {
border-color: rgba(255, 255, 255, 0.2);
background: #111;
}
.preview-column-header select:focus {
outline: none;
border-color: var(--color-accent-primary);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
}
.preview-content {
flex: 1;
overflow-y: auto;
padding: 20px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
color: #ccc;
}
.preview-content pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Settings Column */
.settings-column {
padding: 0;
overflow-y: auto;
background: var(--color-bg-primary);
position: relative;
}
/* Custom Scrollbar */
.settings-column::-webkit-scrollbar {
width: 8px;
}
.settings-column::-webkit-scrollbar-track {
background: transparent;
}
.settings-column::-webkit-scrollbar-thumb {
background: var(--color-bg-scrollbar-thumb);
border-radius: 4px;
}
.settings-column::-webkit-scrollbar-thumb:hover {
background: var(--color-bg-scrollbar-thumb-hover);
}
.preview-content::-webkit-scrollbar {
width: 8px;
}
.preview-content::-webkit-scrollbar-track {
background: transparent;
}
.preview-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
.preview-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* Settings Groups - Compact */
.settings-group {
padding: 14px 16px;
border-bottom: 1px solid var(--color-border-primary);
}
.settings-group:last-child {
border-bottom: none;
}
.settings-group h4 {
margin: 0 0 10px 0;
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
}
/* Filter Chips - Compact */
.chips-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
min-height: 28px;
border: 1px solid var(--color-border-primary);
border-radius: 4px;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
background: var(--color-bg-card);
cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.chip:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-hover);
color: var(--color-text-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.chip:active {
transform: translateY(0);
}
.chip.selected {
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
color: white;
border-color: var(--color-bg-button);
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.25);
}
.chip.selected:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(9, 105, 218, 0.35);
}
/* Form Controls in Modal - Compact */
.settings-group input[type="number"],
.settings-group select {
width: 100%;
background: var(--color-bg-input);
border: 1px solid var(--color-border-primary);
border-radius: 4px;
padding: 6px 10px;
height: 32px;
font-size: 12px;
color: var(--color-text-primary);
transition: all 0.2s;
margin-top: 4px;
}
.settings-group input[type="number"]:hover,
.settings-group select:hover {
border-color: var(--color-border-hover);
}
.settings-group input[type="number"]:focus,
.settings-group select:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
.settings-group label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 4px;
}
/* Checkboxes - Compact */
.settings-group input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
margin-right: 6px;
accent-color: var(--color-accent-primary);
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.checkbox-item {
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 0;
}
.checkbox-item label {
margin: 0;
cursor: pointer;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
}
.checkbox-item:hover label {
color: var(--color-text-primary);
}
/* Number Input Group - Compact */
.number-input-group {
margin-top: 6px;
}
.select-group {
margin-top: 6px;
}
.number-input-group + .number-input-group,
.select-group + .number-input-group,
.number-input-group + .select-group {
margin-top: 10px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ============================================
NEW: Collapsible Sections
============================================ */
.settings-section-collapsible {
border-bottom: 1px solid var(--color-border-primary);
}
.settings-section-collapsible:last-child {
border-bottom: none;
}
.section-header-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.section-header-btn:hover {
background: var(--color-bg-card-hover);
}
.section-header-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
letter-spacing: -0.01em;
}
.section-description {
font-size: 11px;
color: var(--color-text-muted);
font-weight: 400;
}
.chevron-icon {
color: var(--color-text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevron-icon.rotated {
transform: rotate(180deg);
}
.section-content {
padding: 0 16px 16px 16px;
}
/* ============================================
NEW: Chip Groups with All/None
============================================ */
.chip-group {
margin-bottom: 14px;
}
.chip-group:last-child {
margin-bottom: 0;
}
.chip-group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.chip-group-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chip-group-actions {
display: flex;
gap: 4px;
}
.chip-action {
padding: 2px 8px;
font-size: 10px;
font-weight: 500;
color: var(--color-text-muted);
background: transparent;
border: 1px solid var(--color-border-primary);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.chip-action:hover {
color: var(--color-text-primary);
border-color: var(--color-border-hover);
background: var(--color-bg-card-hover);
}
.chip-action.active {
color: var(--color-accent-primary);
border-color: var(--color-accent-primary);
background: var(--color-type-badge-bg);
}
/* ============================================
NEW: Form Fields with Tooltips
============================================ */
.form-field {
margin-bottom: 12px;
}
.form-field:last-child {
margin-bottom: 0;
}
.form-field-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 6px;
}
.tooltip-trigger {
display: inline-flex;
align-items: center;
color: var(--color-text-muted);
cursor: help;
transition: color 0.15s ease;
}
.tooltip-trigger:hover {
color: var(--color-accent-primary);
}
.form-field input[type="number"],
.form-field select {
width: 100%;
background: var(--color-bg-input);
border: 1px solid var(--color-border-primary);
border-radius: 6px;
padding: 8px 12px;
height: 36px;
font-size: 13px;
color: var(--color-text-primary);
transition: all 0.15s ease;
}
.form-field input[type="number"]:hover,
.form-field select:hover {
border-color: var(--color-border-hover);
}
.form-field input[type="number"]:focus,
.form-field select:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
}
/* ============================================
NEW: Toggle Switches
============================================ */
.toggle-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--color-border-secondary);
}
.toggle-row:last-child {
border-bottom: none;
}
.toggle-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.toggle-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-primary);
cursor: pointer;
}
.toggle-description {
font-size: 11px;
color: var(--color-text-muted);
line-height: 1.3;
}
.toggle-switch {
position: relative;
width: 40px;
height: 22px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: 11px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
margin-left: 12px;
padding: 0;
}
.toggle-switch:hover:not(.disabled) {
border-color: var(--color-border-hover);
}
.toggle-switch.on {
background: var(--color-accent-primary);
border-color: var(--color-accent-primary);
}
.toggle-switch.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-switch.on .toggle-knob {
transform: translateX(18px);
}
/* ============================================
NEW: Display Subsections
============================================ */
.display-subsection {
padding: 12px 0;
border-bottom: 1px solid var(--color-border-secondary);
}
.display-subsection:first-child {
padding-top: 0;
}
.display-subsection:last-child {
border-bottom: none;
padding-bottom: 0;
}
.subsection-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
/* ============================================
Improved Chip Styles
============================================ */
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
min-height: 30px;
border: 1px solid var(--color-border-primary);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
background: var(--color-bg-card);
cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.chip:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-accent-primary);
color: var(--color-text-primary);
}
.chip:active {
transform: scale(0.98);
}
.chip.selected {
background: var(--color-accent-primary);
color: white;
border-color: var(--color-accent-primary);
}
.chip.selected:hover {
background: var(--color-bg-button-hover);
border-color: var(--color-bg-button-hover);
}
/* Responsive Modal */
@media (max-width: 900px) {
.modal-body {
grid-template-columns: 1fr;
}
.preview-column {
display: none;
}
}
@media (max-width: 600px) {
.modal-backdrop {
padding: 0;
}
.context-settings-modal {
border-radius: 0;
height: 100vh;
max-height: none;
}
.modal-header {
padding: 12px 16px;
gap: 12px;
}
.preview-selector {
font-size: 11px;
gap: 6px;
}
.preview-selector select {
padding: 5px 10px;
font-size: 11px;
}
.settings-group {
padding: 14px 16px;
}
.section-header-btn {
padding: 12px 14px;
}
.section-content {
padding: 0 14px 14px 14px;
}
.toggle-row {
padding: 8px 0;
}
.toggle-switch {
width: 36px;
height: 20px;
}
.toggle-knob {
width: 14px;
height: 14px;
}
.toggle-switch.on .toggle-knob {
transform: translateX(16px);
}
}
</style>
</head>
+10 -19
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Header } from './components/Header';
import { Feed } from './components/Feed';
import { Sidebar } from './components/Sidebar';
import { ContextSettingsModal } from './components/ContextSettingsModal';
import { useSSE } from './hooks/useSSE';
import { useSettings } from './hooks/useSettings';
import { useStats } from './hooks/useStats';
@@ -12,7 +12,7 @@ import { mergeAndDeduplicateByProject } from './utils/data';
export function App() {
const [currentFilter, setCurrentFilter] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [contextPreviewOpen, setContextPreviewOpen] = useState(false);
const [paginatedObservations, setPaginatedObservations] = useState<Observation[]>([]);
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
@@ -48,9 +48,9 @@ export function App() {
return mergeAndDeduplicateByProject(prompts, paginatedPrompts);
}, [prompts, paginatedPrompts, currentFilter]);
// Toggle sidebar
const toggleSidebar = useCallback(() => {
setSidebarOpen(prev => !prev);
// Toggle context preview modal
const toggleContextPreview = useCallback(() => {
setContextPreviewOpen(prev => !prev);
}, []);
// Handle loading more data
@@ -92,15 +92,13 @@ export function App() {
projects={projects}
currentFilter={currentFilter}
onFilterChange={setCurrentFilter}
onSettingsToggle={toggleSidebar}
sidebarOpen={sidebarOpen}
isProcessing={isProcessing}
queueDepth={queueDepth}
themePreference={preference}
onThemeChange={setThemePreference}
onContextPreviewToggle={toggleContextPreview}
/>
<Feed
observations={allObservations}
summaries={allSummaries}
@@ -110,20 +108,13 @@ export function App() {
hasMore={pagination.observations.hasMore || pagination.summaries.hasMore || pagination.prompts.hasMore}
/>
<Sidebar
isOpen={sidebarOpen}
<ContextSettingsModal
isOpen={contextPreviewOpen}
onClose={toggleContextPreview}
settings={settings}
stats={stats}
onSave={saveSettings}
isSaving={isSaving}
saveStatus={saveStatus}
isConnected={isConnected}
projects={projects}
currentFilter={currentFilter}
onFilterChange={setCurrentFilter}
onSave={saveSettings}
onClose={toggleSidebar}
onRefreshStats={refreshStats}
/>
</>
);
@@ -0,0 +1,552 @@
import React, { useState, useCallback, useEffect } from 'react';
import type { Settings } from '../types';
import { TerminalPreview } from './TerminalPreview';
import { useContextPreview } from '../hooks/useContextPreview';
interface ContextSettingsModalProps {
isOpen: boolean;
onClose: () => void;
settings: Settings;
onSave: (settings: Settings) => void;
isSaving: boolean;
saveStatus: string;
}
// Simple debounce helper
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
let timeoutId: NodeJS.Timeout;
return ((...args: any[]) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), ms);
}) as T;
}
// Collapsible section component
function CollapsibleSection({
title,
description,
children,
defaultOpen = true
}: {
title: string;
description?: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className={`settings-section-collapsible ${isOpen ? 'open' : ''}`}>
<button
className="section-header-btn"
onClick={() => setIsOpen(!isOpen)}
type="button"
>
<div className="section-header-content">
<span className="section-title">{title}</span>
{description && <span className="section-description">{description}</span>}
</div>
<svg
className={`chevron-icon ${isOpen ? 'rotated' : ''}`}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{isOpen && <div className="section-content">{children}</div>}
</div>
);
}
// Chip group with select all/none
function ChipGroup({
label,
options,
selectedValues,
onToggle,
onSelectAll,
onSelectNone
}: {
label: string;
options: string[];
selectedValues: string[];
onToggle: (value: string) => void;
onSelectAll: () => void;
onSelectNone: () => void;
}) {
const allSelected = options.every(opt => selectedValues.includes(opt));
const noneSelected = options.every(opt => !selectedValues.includes(opt));
return (
<div className="chip-group">
<div className="chip-group-header">
<span className="chip-group-label">{label}</span>
<div className="chip-group-actions">
<button
type="button"
className={`chip-action ${allSelected ? 'active' : ''}`}
onClick={onSelectAll}
>
All
</button>
<button
type="button"
className={`chip-action ${noneSelected ? 'active' : ''}`}
onClick={onSelectNone}
>
None
</button>
</div>
</div>
<div className="chips-container">
{options.map(option => (
<button
key={option}
type="button"
className={`chip ${selectedValues.includes(option) ? 'selected' : ''}`}
onClick={() => onToggle(option)}
>
{option}
</button>
))}
</div>
</div>
);
}
// Form field with optional tooltip
function FormField({
label,
tooltip,
children
}: {
label: string;
tooltip?: string;
children: React.ReactNode;
}) {
return (
<div className="form-field">
<label className="form-field-label">
{label}
{tooltip && (
<span className="tooltip-trigger" title={tooltip}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
)}
</label>
{children}
</div>
);
}
// Toggle switch component
function ToggleSwitch({
id,
label,
description,
checked,
onChange,
disabled
}: {
id: string;
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="toggle-row">
<div className="toggle-info">
<label htmlFor={id} className="toggle-label">{label}</label>
{description && <span className="toggle-description">{description}</span>}
</div>
<button
type="button"
id={id}
role="switch"
aria-checked={checked}
className={`toggle-switch ${checked ? 'on' : ''} ${disabled ? 'disabled' : ''}`}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
>
<span className="toggle-knob" />
</button>
</div>
);
}
export function ContextSettingsModal({
isOpen,
onClose,
settings,
onSave,
isSaving,
saveStatus
}: ContextSettingsModalProps) {
const [formState, setFormState] = useState<Settings>(settings);
// MCP toggle state
const [mcpEnabled, setMcpEnabled] = useState(true);
const [mcpToggling, setMcpToggling] = useState(false);
const [mcpStatus, setMcpStatus] = useState('');
// Create debounced save function
const debouncedSave = useCallback(
debounce((newSettings: Settings) => {
onSave(newSettings);
}, 300),
[onSave]
);
// Update form state when settings prop changes
useEffect(() => {
setFormState(settings);
}, [settings]);
// Fetch MCP status on mount
useEffect(() => {
fetch('/api/mcp/status')
.then(res => res.json())
.then(data => setMcpEnabled(data.enabled))
.catch(error => console.error('Failed to load MCP status:', error));
}, []);
// Get context preview based on current form state
const { preview, isLoading, error, projects, selectedProject, setSelectedProject } = useContextPreview(formState);
const updateSetting = useCallback((key: keyof Settings, value: string) => {
const newState = { ...formState, [key]: value };
setFormState(newState);
debouncedSave(newState);
}, [formState, debouncedSave]);
const toggleBoolean = useCallback((key: keyof Settings) => {
const currentValue = formState[key];
const newValue = currentValue === 'true' ? 'false' : 'true';
updateSetting(key, newValue);
}, [formState, updateSetting]);
const toggleArrayValue = useCallback((key: keyof Settings, value: string) => {
const currentValue = formState[key] || '';
const currentArray = currentValue ? currentValue.split(',') : [];
const newArray = currentArray.includes(value)
? currentArray.filter(v => v !== value)
: [...currentArray, value];
updateSetting(key, newArray.join(','));
}, [formState, updateSetting]);
const getArrayValues = useCallback((key: keyof Settings): string[] => {
const currentValue = formState[key] || '';
return currentValue ? currentValue.split(',') : [];
}, [formState]);
const setAllArrayValues = useCallback((key: keyof Settings, values: string[]) => {
updateSetting(key, values.join(','));
}, [updateSetting]);
// Handle MCP toggle
const handleMcpToggle = async (enabled: boolean) => {
setMcpToggling(true);
setMcpStatus('Toggling...');
try {
const response = await fetch('/api/mcp/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const result = await response.json();
if (result.success) {
setMcpEnabled(result.enabled);
setMcpStatus('Updated (restart to apply)');
setTimeout(() => setMcpStatus(''), 3000);
} else {
setMcpStatus(`Error: ${result.error}`);
setTimeout(() => setMcpStatus(''), 3000);
}
} catch (err) {
setMcpStatus(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setMcpStatus(''), 3000);
} finally {
setMcpToggling(false);
}
};
// Handle ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
const observationTypes = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change'];
const observationConcepts = ['how-it-works', 'why-it-exists', 'what-changed', 'problem-solution', 'gotcha', 'pattern', 'trade-off'];
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="context-settings-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="modal-header">
<h2>Settings</h2>
<div className="header-controls">
<a
href="https://docs.claude-mem.ai"
target="_blank"
rel="noopener noreferrer"
title="Documentation"
className="modal-icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
</a>
<a
href="https://x.com/Claude_Memory"
target="_blank"
rel="noopener noreferrer"
title="X (Twitter)"
className="modal-icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<label className="preview-selector">
Preview for:
<select
value={selectedProject || ''}
onChange={(e) => setSelectedProject(e.target.value)}
>
{projects.map(project => (
<option key={project} value={project}>{project}</option>
))}
</select>
</label>
<button
onClick={onClose}
className="modal-close-btn"
title="Close (Esc)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/* Body - 2 columns */}
<div className="modal-body">
{/* Left column - Terminal Preview */}
<div className="preview-column">
<div className="preview-content">
{error ? (
<div style={{ color: '#ff6b6b' }}>
Error loading preview: {error}
</div>
) : (
<TerminalPreview content={preview} isLoading={isLoading} />
)}
</div>
</div>
{/* Right column - Settings Panel */}
<div className="settings-column">
{/* Section 1: Loading */}
<CollapsibleSection
title="Loading"
description="How many observations to inject"
>
<FormField
label="Observations"
tooltip="Number of recent observations to include in context (1-200)"
>
<input
type="number"
min="1"
max="200"
value={formState.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_OBSERVATIONS', e.target.value)}
/>
</FormField>
<FormField
label="Sessions"
tooltip="Number of recent sessions to pull observations from (1-50)"
>
<input
type="number"
min="1"
max="50"
value={formState.CLAUDE_MEM_CONTEXT_SESSION_COUNT || '10'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_SESSION_COUNT', e.target.value)}
/>
</FormField>
</CollapsibleSection>
{/* Section 2: Filters */}
<CollapsibleSection
title="Filters"
description="Which observation types to include"
>
<ChipGroup
label="Type"
options={observationTypes}
selectedValues={getArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES')}
onToggle={(value) => toggleArrayValue('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', value)}
onSelectAll={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', observationTypes)}
onSelectNone={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES', [])}
/>
<ChipGroup
label="Concept"
options={observationConcepts}
selectedValues={getArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS')}
onToggle={(value) => toggleArrayValue('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', value)}
onSelectAll={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', observationConcepts)}
onSelectNone={() => setAllArrayValues('CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS', [])}
/>
</CollapsibleSection>
{/* Section 3: Display */}
<CollapsibleSection
title="Display"
description="What to show in context tables"
>
<div className="display-subsection">
<span className="subsection-label">Full Observations</span>
<FormField
label="Count"
tooltip="How many observations show expanded details (0-20)"
>
<input
type="number"
min="0"
max="20"
value={formState.CLAUDE_MEM_CONTEXT_FULL_COUNT || '5'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_FULL_COUNT', e.target.value)}
/>
</FormField>
<FormField
label="Field"
tooltip="Which field to expand for full observations"
>
<select
value={formState.CLAUDE_MEM_CONTEXT_FULL_FIELD || 'narrative'}
onChange={(e) => updateSetting('CLAUDE_MEM_CONTEXT_FULL_FIELD', e.target.value)}
>
<option value="narrative">Narrative</option>
<option value="facts">Facts</option>
</select>
</FormField>
</div>
<div className="display-subsection">
<span className="subsection-label">Token Economics</span>
<div className="toggle-group">
<ToggleSwitch
id="show-read-tokens"
label="Read cost"
description="Tokens to read this observation"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS')}
/>
<ToggleSwitch
id="show-work-tokens"
label="Work investment"
description="Tokens spent creating this observation"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS')}
/>
<ToggleSwitch
id="show-savings-amount"
label="Savings"
description="Total tokens saved by reusing context"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT')}
/>
</div>
</div>
</CollapsibleSection>
{/* Section 4: Advanced */}
<CollapsibleSection
title="Advanced"
description="Model selection and integrations"
defaultOpen={false}
>
<FormField
label="Model"
tooltip="AI model used for generating observations"
>
<select
value={formState.CLAUDE_MEM_MODEL || 'claude-haiku-4-5'}
onChange={(e) => updateSetting('CLAUDE_MEM_MODEL', e.target.value)}
>
<option value="claude-haiku-4-5">claude-haiku-4-5 (fastest)</option>
<option value="claude-sonnet-4-5">claude-sonnet-4-5 (balanced)</option>
<option value="claude-opus-4">claude-opus-4 (highest quality)</option>
</select>
</FormField>
<FormField
label="Worker Port"
tooltip="Port for the background worker service"
>
<input
type="number"
min="1024"
max="65535"
value={formState.CLAUDE_MEM_WORKER_PORT || '37777'}
onChange={(e) => updateSetting('CLAUDE_MEM_WORKER_PORT', e.target.value)}
/>
</FormField>
<div className="toggle-group" style={{ marginTop: '12px' }}>
<ToggleSwitch
id="mcp-enabled"
label="MCP search server"
description={mcpStatus || "Enable Model Context Protocol search"}
checked={mcpEnabled}
onChange={handleMcpToggle}
disabled={mcpToggling}
/>
<ToggleSwitch
id="show-last-summary"
label="Include last summary"
description="Add previous session's summary to context"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY')}
/>
<ToggleSwitch
id="show-last-message"
label="Include last message"
description="Add previous session's final message"
checked={formState.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true'}
onChange={() => toggleBoolean('CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE')}
/>
</div>
</CollapsibleSection>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import React from 'react';
import { useGitHubStars } from '../hooks/useGitHubStars';
import { formatStarCount } from '../utils/formatNumber';
interface GitHubStarsButtonProps {
username: string;
repo: string;
className?: string;
}
export function GitHubStarsButton({ username, repo, className = '' }: GitHubStarsButtonProps) {
const { stars, isLoading, error } = useGitHubStars(username, repo);
const repoUrl = `https://github.com/${username}/${repo}`;
// Graceful degradation: on error, show just the icon (like original static link)
if (error) {
return (
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
title="GitHub"
className="icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
);
}
return (
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className={`github-stars-btn ${className}`}
title={`Star us on GitHub${stars !== null ? ` (${stars.toLocaleString()} stars)` : ''}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: '6px' }}>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: '4px' }}>
<path d="M12 .587l3.668 7.431 8.2 1.192-5.934 5.787 1.4 8.166L12 18.896l-7.334 3.867 1.4-8.166-5.934-5.787 8.2-1.192z"/>
</svg>
<span className={isLoading ? 'stars-loading' : 'stars-count'}>
{isLoading ? '...' : (stars !== null ? formatStarCount(stars) : '—')}
</span>
</a>
);
}
+9 -44
View File
@@ -1,18 +1,18 @@
import React from 'react';
import { ThemeToggle } from './ThemeToggle';
import { ThemePreference } from '../hooks/useTheme';
import { GitHubStarsButton } from './GitHubStarsButton';
interface HeaderProps {
isConnected: boolean;
projects: string[];
currentFilter: string;
onFilterChange: (filter: string) => void;
onSettingsToggle: () => void;
sidebarOpen: boolean;
isProcessing: boolean;
queueDepth: number;
themePreference: ThemePreference;
onThemeChange: (theme: ThemePreference) => void;
onContextPreviewToggle: () => void;
}
export function Header({
@@ -20,12 +20,11 @@ export function Header({
projects,
currentFilter,
onFilterChange,
onSettingsToggle,
sidebarOpen,
isProcessing,
queueDepth,
themePreference,
onThemeChange
onThemeChange,
onContextPreviewToggle
}: HeaderProps) {
return (
<div className="header">
@@ -41,51 +40,17 @@ export function Header({
<span className="logo-text">claude-mem</span>
</h1>
<div className="status">
<a
href="https://docs.claude-mem.ai"
target="_blank"
rel="noopener noreferrer"
title="Documentation"
className="icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
</a>
<a
href="https://github.com/thedotmack/claude-mem/"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
className="icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
<a
href="https://x.com/Claude_Memory"
target="_blank"
rel="noopener noreferrer"
title="X (Twitter)"
className="icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<GitHubStarsButton username="thedotmack" repo="claude-mem" />
<a
href="https://discord.gg/J4wttp9vDu"
target="_blank"
rel="noopener noreferrer"
className="community-btn"
className="icon-link"
title="Join our Discord community"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: '6px' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
<span>Community</span>
</a>
<select
value={currentFilter}
@@ -101,8 +66,8 @@ export function Header({
onThemeChange={onThemeChange}
/>
<button
className={`settings-btn ${sidebarOpen ? 'active' : ''}`}
onClick={onSettingsToggle}
className="settings-btn"
onClick={onContextPreviewToggle}
title="Settings"
>
<svg className="settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -0,0 +1,136 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from 'react';
import AnsiToHtml from 'ansi-to-html';
interface TerminalPreviewProps {
content: string;
isLoading?: boolean;
className?: string;
}
const ansiConverter = new AnsiToHtml({
fg: '#dcd6cc',
bg: '#252320',
newline: false,
escapeXML: true,
stream: false
});
export function TerminalPreview({ content, isLoading = false, className = '' }: TerminalPreviewProps) {
const preRef = useRef<HTMLPreElement>(null);
const scrollTopRef = useRef(0);
const [wordWrap, setWordWrap] = useState(true);
const html = useMemo(() => {
// Save scroll position before content changes
if (preRef.current) {
scrollTopRef.current = preRef.current.scrollTop;
}
if (!content) return '';
return ansiConverter.toHtml(content);
}, [content]);
// Restore scroll position after render
useLayoutEffect(() => {
if (preRef.current && scrollTopRef.current > 0) {
preRef.current.scrollTop = scrollTopRef.current;
}
}, [html]);
const preStyle: React.CSSProperties = {
padding: '16px',
margin: 0,
fontFamily: 'var(--font-terminal)',
fontSize: '12px',
lineHeight: '1.6',
overflow: 'auto',
color: 'var(--color-text-primary)',
backgroundColor: 'var(--color-bg-card)',
whiteSpace: wordWrap ? 'pre-wrap' : 'pre',
wordBreak: wordWrap ? 'break-word' : 'normal',
position: 'absolute',
inset: 0,
};
return (
<div
className={className}
style={{
backgroundColor: 'var(--color-bg-card)',
border: '1px solid var(--color-border-primary)',
borderRadius: '8px',
overflow: 'hidden',
height: '100%',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3)'
}}
>
{/* Window chrome */}
<div
style={{
padding: '12px',
borderBottom: '1px solid var(--color-border-primary)',
display: 'flex',
gap: '6px',
alignItems: 'center',
backgroundColor: 'var(--color-bg-header)'
}}
>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', backgroundColor: '#ff5f57' }} />
<div style={{ width: '12px', height: '12px', borderRadius: '50%', backgroundColor: '#ffbd2e' }} />
<div style={{ width: '12px', height: '12px', borderRadius: '50%', backgroundColor: '#28c840' }} />
<button
onClick={() => setWordWrap(!wordWrap)}
style={{
marginLeft: 'auto',
padding: '4px 8px',
fontSize: '11px',
fontWeight: 500,
color: wordWrap ? 'var(--color-text-secondary)' : 'var(--color-accent-primary)',
backgroundColor: 'transparent',
border: '1px solid',
borderColor: wordWrap ? 'var(--color-border-primary)' : 'var(--color-accent-primary)',
borderRadius: '4px',
cursor: 'pointer',
transition: 'all 0.2s',
whiteSpace: 'nowrap'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-accent-primary)';
e.currentTarget.style.color = 'var(--color-accent-primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = wordWrap ? 'var(--color-border-primary)' : 'var(--color-accent-primary)';
e.currentTarget.style.color = wordWrap ? 'var(--color-text-secondary)' : 'var(--color-accent-primary)';
}}
title={wordWrap ? 'Disable word wrap (scroll horizontally)' : 'Enable word wrap'}
>
{wordWrap ? '⤢ Wrap' : '⇄ Scroll'}
</button>
</div>
{/* Content area */}
{isLoading ? (
<div
style={{
padding: '16px',
fontFamily: 'var(--font-terminal)',
fontSize: '12px',
color: 'var(--color-text-secondary)'
}}
>
Loading preview...
</div>
) : (
<div style={{ position: 'relative', flex: 1, overflow: 'hidden' }}>
<pre
ref={preRef}
style={preStyle}
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useState, useEffect, useCallback } from 'react';
import type { Settings } from '../types';
interface UseContextPreviewResult {
preview: string;
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
projects: string[];
selectedProject: string | null;
setSelectedProject: (project: string) => void;
}
export function useContextPreview(settings: Settings): UseContextPreviewResult {
const [preview, setPreview] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [projects, setProjects] = useState<string[]>([]);
const [selectedProject, setSelectedProject] = useState<string | null>(null);
// Fetch projects on mount
useEffect(() => {
async function fetchProjects() {
try {
const response = await fetch('/api/projects');
const data = await response.json();
if (data.projects && data.projects.length > 0) {
setProjects(data.projects);
setSelectedProject(data.projects[0]); // Default to first project
}
} catch (err) {
console.error('Failed to fetch projects:', err);
}
}
fetchProjects();
}, []);
const refresh = useCallback(async () => {
if (!selectedProject) {
setPreview('No project selected');
return;
}
setIsLoading(true);
setError(null);
try {
const params = new URLSearchParams({
project: selectedProject
});
const response = await fetch(`/api/context/preview?${params}`);
const text = await response.text();
if (response.ok) {
setPreview(text);
} else {
setError('Failed to load preview');
}
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoading(false);
}
}, [selectedProject]);
// Debounced refresh when settings or selectedProject change
useEffect(() => {
const timeout = setTimeout(() => {
refresh();
}, 300);
return () => clearTimeout(timeout);
}, [settings, refresh]);
return { preview, isLoading, error, refresh, projects, selectedProject, setSelectedProject };
}
+46
View File
@@ -0,0 +1,46 @@
import { useState, useEffect, useCallback } from 'react';
export interface GitHubStarsData {
stargazers_count: number;
watchers_count: number;
forks_count: number;
}
export interface UseGitHubStarsReturn {
stars: number | null;
isLoading: boolean;
error: Error | null;
}
export function useGitHubStars(username: string, repo: string): UseGitHubStarsReturn {
const [stars, setStars] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchStars = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(`https://api.github.com/repos/${username}/${repo}`);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const data: GitHubStarsData = await response.json();
setStars(data.stargazers_count);
} catch (error) {
console.error('Failed to fetch GitHub stars:', error);
setError(error instanceof Error ? error : new Error('Unknown error'));
} finally {
setIsLoading(false);
}
}, [username, repo]);
useEffect(() => {
fetchStars();
}, [fetchStars]);
return { stars, isLoading, error };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Formats a number into compact notation with k/M suffixes
* Examples:
* 999 "999"
* 1234 "1.2k"
* 45678 "45.7k"
* 1234567 "1.2M"
*/
export function formatStarCount(count: number): string {
if (count < 1000) {
return count.toString();
}
if (count < 1000000) {
// Format as k (thousands)
const thousands = count / 1000;
return `${thousands.toFixed(1)}k`;
}
// Format as M (millions)
const millions = count / 1000000;
return `${millions.toFixed(1)}M`;
}