diff --git a/application/client/src/app/schema/ids.ts b/application/client/src/app/schema/ids.ts index a71a3786fc..09ef381438 100644 --- a/application/client/src/app/schema/ids.ts +++ b/application/client/src/app/schema/ids.ts @@ -12,3 +12,4 @@ export const SIDEBAR_TAB_ATTACHMENTS = unique(); export const SIDEBAR_TAB_FILTERS = unique(); export const SIDEBAR_TAB_COMMENTS = unique(); export const SIDEBAR_TAB_TEAMWORK = unique(); +export const SIDEBAR_TAB_CHAT = unique(); diff --git a/application/client/src/app/service/session/session.ts b/application/client/src/app/service/session/session.ts index 3a865e1818..d55e43e076 100644 --- a/application/client/src/app/service/session/session.ts +++ b/application/client/src/app/service/session/session.ts @@ -190,6 +190,19 @@ export class Session extends Base { }, }, }); + this._sidebar.add({ + uuid: ids.SIDEBAR_TAB_CHAT, + name: 'Chat', + active: false, + closable: false, + uppercaseTitle: true, + content: { + factory: components.get('app-views-chat'), + inputs: { + session: this, + }, + }, + }); } public init(): Promise { diff --git a/application/client/src/app/ui/views/sidebar/chat/component.ts b/application/client/src/app/ui/views/sidebar/chat/component.ts new file mode 100644 index 0000000000..86d37f00ae --- /dev/null +++ b/application/client/src/app/ui/views/sidebar/chat/component.ts @@ -0,0 +1,162 @@ +import { Component, Input, ChangeDetectorRef, AfterContentInit, HostListener } from '@angular/core'; +import { Session } from '@service/session'; +import { Ilc, IlcInterface } from '@env/decorators/component'; +import { Initial } from '@env/decorators/initial'; +import { ChangesDetector } from '@ui/env/extentions/changes'; + +interface Message { + //TODO: Is a message ID needed? + // id: number; + sender: string; + text: string; + timestamp: Date; + type: 'prompt' | 'response' | 'system'; + pending: boolean; // Shows pending animation while waiting for response +} + +// TODO: Chat config +interface ChatConfig { + enabled: boolean; + apiKey: string; + model: string; +} + +@Component({ + selector: 'app-views-chat', + templateUrl: './template.html', + styleUrls: ['./styles.less'], + standalone: false, +}) +@Initial() +@Ilc() +export class Chat extends ChangesDetector implements AfterContentInit { + // Session from parent component + @Input() session!: Session; + + // Configuration storing chat related settings + public config: ChatConfig = { + enabled: false, + apiKey: '', + model: '', + }; + + // Messages being displayed. + //TODO: Should this be stored in the backend? + public messages: Message[] = []; + + // Content of the user's input box + public userInput: string = ''; + + constructor(cdRef: ChangeDetectorRef) { + super(cdRef); + } + + public ngAfterContentInit(): void { + // TODO: persistance / storing state + this.detectChanges(); + } + + // Toggle chat on/off from the actions menu + public onToggleChat(): void { + this.config.enabled = !this.config.enabled; + + // Show welcome message when chat is enabled for the first time + if (this.config.enabled && this.messages.length === 0) { + this.printMessage('AI Assistant', 'Hello! How can I help you today?', 'response'); + } + this.detectChanges(); + } + + // Clear all chat history + public onClearHistory(): void { + this.messages = []; + this.detectChanges(); + } + + // Open chat configuration dialog + public onConfigureChat(): void { + // TODO: Open configuration dialog + } + + private printMessage(sender: string, text: string, type: Message['type'], pending: boolean = false): Message { + const message: Message = { + sender, + text, + timestamp: new Date(), + type, + pending, + }; + this.messages.push(message); + this.detectChanges(); + return message; + } + + // Handles user input from the chat's user input box + public onUserInput(): void { + // Do nothing when chat feature is disabled + if (!this.config.enabled) return; + + // TODO: Sanitation probably already happens in the backend? + const input = this.userInput.trim(); + if (!input) return; + + // Instantly print user input without any checks. + this.printMessage('You', input, 'prompt'); + + // Clear input field + this.userInput = ''; + + // TODO: Send to MCPClient (via IPC?) + + // Show pending indicator while waiting for response + this.printMessage('AI Assistant', '', 'response', true); + + //TODO: remove Simulated response with random delay (1-5 seconds) + const delayMs = Math.random() * 8000; + this.simulateResponse(delayMs); + } + + // + public simulateResponse(delay_ms: number): void { + setTimeout(() => { + // Remove pending message and add actual response + if (this.messages.length > 0 && this.messages[this.messages.length - 1].pending) { + this.messages.pop(); + } + this.onRemoteMessage('AI assistant', 'This is a simulated AI assistant response'); + }, delay_ms); + } + + //TODO: Handle messages received from the MCPClient (via IPC?) + public onRemoteMessage(sender: string, text: string): void { + this.printMessage(sender, text, 'response'); + } + + public onKeyPress(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.onUserInput(); + } + } + + public autoResizeInputArea(textarea: HTMLTextAreaElement): void { + const defaultHeight = 32; //TODO: match what we set in style + + // Temporarily reset to auto to measure actual content height + textarea.style.height = 'auto'; + + // Calculate content height + const newHeight = Math.max(textarea.scrollHeight, defaultHeight); + textarea.style.height = newHeight + 'px'; + } + + public focusInputArea(textarea: HTMLTextAreaElement): void { + if (textarea) { + textarea.focus(); + } +} +} + +// Export the component class as implementing the IlcInterface so other +// parts of the application can access the decorated instance methods +export interface Chat extends IlcInterface {} diff --git a/application/client/src/app/ui/views/sidebar/chat/module.ts b/application/client/src/app/ui/views/sidebar/chat/module.ts new file mode 100644 index 0000000000..f32a35c274 --- /dev/null +++ b/application/client/src/app/ui/views/sidebar/chat/module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { Chat } from './component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + MatMenuModule, + MatIconModule, + MatDividerModule, + ], + declarations: [Chat], + exports: [Chat], +}) +export class ChatModule {} \ No newline at end of file diff --git a/application/client/src/app/ui/views/sidebar/chat/styles.less b/application/client/src/app/ui/views/sidebar/chat/styles.less new file mode 100644 index 0000000000..e0d424445a --- /dev/null +++ b/application/client/src/app/ui/views/sidebar/chat/styles.less @@ -0,0 +1,327 @@ +@import '../../../styles/variables.less'; + +:host { + position: absolute; + display: block; + padding: 0; + margin: 0; + bottom: 0.5rem; + right: 0.5rem; + left: 0.5rem; + top: 0.5rem; + overflow: hidden; + outline: none; + + & > div.caption { + position: sticky !important; + top: 0; + left: 0; + z-index: 1; + background: var(--scheme-color-5); + display: flex; + padding: 0 22px; + margin: 0; + text-align: left; + align-items: center; + white-space: nowrap; + font-size: 0.9rem; + font-weight: 400; + height: 32px; + + span.title { + color: var(--scheme-color-2); + } + + span.subtitle { + padding-left: 6px; + color: var(--scheme-color-3); + } + + & span.filler { + flex: auto; + } + + .small-icon-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + cursor: pointer; + color: var(--scheme-color-2); + transition: color 0.2s; + margin-left: 4px; + + &:hover { + color: var(--scheme-color-0); + } + } + } + + & > div.chat-content { + position: absolute; + display: block; + top: 32px; + bottom: 0; + width: 100%; + left: 0; + overflow: hidden; + } + + & .hidden { + margin-top: 8px; + } + + & p.info { + font-size: 13px; + color: var(--scheme-color-2); + margin: 16px 24px; + } + + & div.chat-panel { + position: absolute; + display: flex; + flex-direction: column; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow: hidden; + + & .message-window { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .input-area { + display: flex; + flex-direction: column; + padding: 12px 16px; + background-color: var(--scheme-color-5); + border-top: 1px solid var(--scheme-color-3); + flex-shrink: 0; + + // Unified bubble look (textarea + button bar) + .input-wrapper, + .button-bar { + background-color: var(--scheme-color-4); + border-left: 1px solid var(--scheme-color-3); + border-right: 1px solid var(--scheme-color-3); + } + + .input-wrapper { + border-top: 1px solid var(--scheme-color-3); + border-radius: 6px 6px 0 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .message-input { + box-sizing: border-box; + width: 100%; + padding: 8px 12px 10px 12px; + border: none; + outline: none; + background: transparent; + color: var(--scheme-color-1); + font-size: 13px; + line-height: 1.4; + resize: none; + overflow-y: auto; + max-height: 200px; + min-height: 32px; + transition: height 0.15s ease; + word-break: break-word; + white-space: pre-wrap; + + // &::placeholder { + // color: var(--scheme-color-3); + // } + + } + + .button-bar { + border-bottom: 1px solid var(--scheme-color-3); + border-top: none; // remove visible divider between sections + border-radius: 0 0 6px 6px; + display: flex; + justify-content: flex-end; + align-items: center; + padding: 5px 5px 5px 5px; + cursor: text; // indicates click will focus input + + // Ensures perfect seam + margin-top: -1px; // overlap 1px border to hide seam + } + + .send-button { + width: 32px; + height: 32px; + padding: 0 12px; + border: 1px solid var(--scheme-color-4); + border-radius: 4px; + background-color: var(--scheme-color-accent); + color: var(--scheme-color-1); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; + transition: background-color 0.15s ease, transform 0.08s ease; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: visible; + } + + &:hover { + background-color: var(--scheme-color-accent-lighten-20); + color: var(--scheme-color-0); + } + + &:active { + transform: scale(0.98); + } + + &.mat-mini-fab { + border-radius: 4px !important; + box-shadow: none !important; + min-width: 96px !important; + height: 32px !important; + padding: 0 12px !important; + } + } + } + } +} + +.message { + display: flex; + flex-direction: column; + max-width: 85%; + + &.user-message { + align-self: flex-end; + align-items: flex-end; + } + + &.ai-message, + &.system-message { + align-self: flex-start; + align-items: flex-start; + } +} + +.message-header { + display: flex; + gap: 8px; + margin-bottom: 4px; + font-size: 11px; + color: var(--scheme-color-3); + + .sender { + font-weight: 500; + } + + .timestamp { + font-weight: 300; + } +} + +.message-bubble { + padding: 8px 12px; + word-wrap: break-word; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-size: 13px; + border-radius: 12px; + line-height: 1.4; + + .user-message & { + background-color: var(--scheme-color-5); + border-radius: 12px 2px 12px 12px; + color: var(--scheme-color-1); + border: 1px solid var(--scheme-color-3); + } + + .ai-message & { + background-color: var(--scheme-color-accent); + border-radius: 2px 12px 12px 12px; + color: var(--scheme-color-0); + } + + .system-message & { + background-color: var(--scheme-color-accent); + border-radius: 12px 12px 12px 2px; + color: var(--scheme-color-0); + } + + .pending-indicator { + display: flex; + gap: 4px; + align-items: center; + padding: 0.25rem 0; + + .dot { + width: 4px; + height: 4px; + background-color: currentColor; + border-radius: 50%; + opacity: 0.6; + animation: message-pending 1.4s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } +} + +// Scrollbar styling +:host ::-webkit-scrollbar, +.message-window::-webkit-scrollbar { + width: 8px; +} + +:host ::-webkit-scrollbar-track, +.message-window::-webkit-scrollbar-track { + background: var(--scheme-color-4); +} + +:host ::-webkit-scrollbar-thumb, +.message-window::-webkit-scrollbar-thumb { + background: var(--scheme-color-3); + border-radius: 4px; + + &:hover { + background: var(--scheme-color-3); + } +} + +// Pending animation for messages +@keyframes message-pending { + 0%, 60%, 100% { + opacity: 0.6; + transform: translateY(0); + } + 30% { + opacity: 1; + transform: translateY(-8px); + } +} \ No newline at end of file diff --git a/application/client/src/app/ui/views/sidebar/chat/template.html b/application/client/src/app/ui/views/sidebar/chat/template.html new file mode 100644 index 0000000000..b2b41b5c44 --- /dev/null +++ b/application/client/src/app/ui/views/sidebar/chat/template.html @@ -0,0 +1,89 @@ +
+ Chat + + + +
+ +
+ + + + +
+
+
+
+ {{ message.sender }} + {{ message.timestamp | date:'short' }} +
+
+
+ + + +
+ {{ message.text }} +
+
+
+ +
+
+ +
+ +
+ +
+
+
+
+ + + + + + + + + + + + diff --git a/application/client/src/app/ui/views/sidebar/comments/template.html b/application/client/src/app/ui/views/sidebar/comments/template.html index 8e7410c6e5..85df3dad78 100644 --- a/application/client/src/app/ui/views/sidebar/comments/template.html +++ b/application/client/src/app/ui/views/sidebar/comments/template.html @@ -1,97 +1,97 @@ -
- Comments - ({{comments.length}}) - - - - - -
-
- - +
+ Comments + ({{comments.length}}) + + + + + +
+
+ + -