Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1049,7 +1049,7 @@ <h5 class="mb-0 fw-semibold text-truncate">
}
} @else if (item.kind === 'ticketAutomationRun') {
<div class="d-flex justify-content-start">
<div class="card border-secondary bg-body chat-message-card">
<div class="card bg-body chat-message-card">
<div class="card-body chat-message-body">
<div class="fw-semibold text-truncate" [attr.title]="item.payload.ticket.title">
{{ item.payload.ticket.title }}
Expand Down Expand Up @@ -1089,7 +1089,7 @@ <h5 class="mb-0 fw-semibold text-truncate">
</div>
} @else if (item.kind === 'chatPlan') {
<div class="d-flex justify-content-start">
<div class="card border-secondary bg-body chat-message-card">
<div class="card bg-body chat-message-card">
<div class="card-body chat-message-body">
<div class="d-flex align-items-start gap-2 min-width-0">
<fpc-badge
Expand Down Expand Up @@ -1294,25 +1294,8 @@ <h5 class="mb-0 fw-semibold text-truncate">
</div>
}
<div class="chat-input-group">
<div
class="form-control chat-textarea-container"
[class.chat-textarea-container--command-marked]="!!selectedCommand()"
>
@if (selectedCommand(); as markedCommand) {
<div class="chat-command-mark">
<fpc-badge variant="info" color="primary" class="chat-command-mark__badge">
<span class="font-monospace">{{ markedCommand }}</span>
<button
type="button"
class="btn-close chat-command-mark__clear"
(click)="clearSelectedCommand()"
i18n-aria-label="@@featureChat-clearCommandAria"
aria-label="Clear command"
></button>
</fpc-badge>
</div>
}
@if (slashCommandMenuOpen()) {
<div class="form-control chat-textarea-container">
@if (isSlashCommandMenuOpenFor('chat')) {
<div
class="dropdown-menu show fpc-typeahead-select__menu chat-command-typeahead"
role="listbox"
Expand All @@ -1326,7 +1309,7 @@ <h5 class="mb-0 fw-semibold text-truncate">
role="option"
[class.active]="i === slashHighlightIndex()"
[attr.aria-selected]="i === slashHighlightIndex()"
(mousedown)="pickSlashCommand(command, $event)"
(mousedown)="pickSlashCommand(command, $event, 'chat')"
>
<span class="d-flex align-items-baseline justify-content-between gap-2">
<span class="font-monospace small fw-medium">{{ command.name }}</span>
Expand All @@ -1349,10 +1332,11 @@ <h5 class="mb-0 fw-semibold text-truncate">
i18n-placeholder="@@featureChat-typeMessagePlaceholder"
[value]="chatMessage()"
(input)="onChatMessageInput($any($event.target).value)"
(focus)="onChatMessageFocus()"
(keydown)="onChatInputKeydown($event)"
[attr.aria-autocomplete]="slashCommandMenuOpen() ? 'list' : null"
[attr.aria-controls]="slashCommandMenuOpen() ? 'chatCommandTypeaheadListbox' : null"
[attr.aria-expanded]="slashCommandMenuOpen()"
[attr.aria-autocomplete]="isSlashCommandMenuOpenFor('chat') ? 'list' : null"
[attr.aria-controls]="isSlashCommandMenuOpenFor('chat') ? 'chatCommandTypeaheadListbox' : null"
[attr.aria-expanded]="isSlashCommandMenuOpenFor('chat')"
[disabled]="
!(socketConnected$ | async) ||
(socketReconnecting$ | async) ||
Expand Down Expand Up @@ -1760,13 +1744,50 @@ <h5 class="mb-0 fw-semibold text-truncate">
></button>
</div>
}
@if (isSlashCommandMenuOpenFor('planRefine')) {
<div
class="dropdown-menu show fpc-typeahead-select__menu chat-command-typeahead"
role="listbox"
id="chatPlanRefineCommandTypeaheadListbox"
>
<div class="fpc-typeahead-select__suggestions">
@for (command of filteredSlashCommands(); track command.name; let i = $index) {
<button
type="button"
class="dropdown-item py-2 text-start"
role="option"
[class.active]="i === slashHighlightIndex()"
[attr.aria-selected]="i === slashHighlightIndex()"
(mousedown)="pickSlashCommand(command, $event, 'planRefine')"
>
<span class="d-flex align-items-baseline justify-content-between gap-2">
<span class="font-monospace small fw-medium">{{ command.name }}</span>
@if (command.source && command.source !== 'command') {
<span class="text-muted small text-uppercase flex-shrink-0">{{ command.source }}</span>
}
</span>
@if (command.description) {
<span class="d-block text-muted small text-truncate">{{ command.description }}</span>
}
</button>
}
</div>
</div>
}
<textarea
id="chatPlanRefineInput"
class="chat-plan-refine-textarea"
rows="3"
[placeholder]="chatPlanRefinePlaceholder"
[value]="planRefineMessage()"
(input)="planRefineMessage.set($any($event.target).value)"
(input)="onPlanRefineMessageInput($any($event.target).value)"
(focus)="onPlanRefineMessageFocus()"
(keydown)="onPlanRefineInputKeydown($event)"
[attr.aria-autocomplete]="isSlashCommandMenuOpenFor('planRefine') ? 'list' : null"
[attr.aria-controls]="
isSlashCommandMenuOpenFor('planRefine') ? 'chatPlanRefineCommandTypeaheadListbox' : null
"
[attr.aria-expanded]="isSlashCommandMenuOpenFor('planRefine')"
[disabled]="plan.status === 'refining'"
></textarea>
<div class="chat-plan-refine-toolbar d-flex gap-1 align-items-end justify-content-end overflow-hidden">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,20 +169,6 @@
position: relative;
}

.chat-command-mark {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem 0;
}

.chat-command-mark__badge {
max-width: 100%;
}

.chat-command-mark__clear {
font-size: 0.65rem;
}

.chat-command-typeahead {
position: absolute;
left: 0;
Expand Down Expand Up @@ -218,10 +204,6 @@
padding-left: 0;
}

.chat-textarea-container--command-marked {
padding-top: 0;
}

// Textarea body
.chat-textarea {
min-width: 0; // Allow input to shrink
Expand All @@ -242,6 +224,7 @@
}

.chat-plan-refine-composer {
position: relative;
display: flex;
flex-direction: column;
border: 1px solid var(--bs-border-color);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ import {
} from './agent-chat-response-markdown';
import { accumulateStreamingTurnFromEvents } from './agent-chat-streaming-aggregate';
import { AgentChatTodosExpandCoordinator } from './agent-chat-todos-expand.coordinator';
import { sanitizeAndTrustMarkdownHtml } from './chat-markdown-html';
import { mergeTicketAutomationChatCardPayload } from './chat-automation-card-merge';
import { sanitizeAndTrustMarkdownHtml } from './chat-markdown-html';
import {
chatPlanStateBadgeColor as chatPlanStateBadgeColorFn,
chatPlanStateLabel as chatPlanStateLabelFn,
Expand Down Expand Up @@ -208,6 +208,8 @@ type ChatMessageWithFilter = {
} | null;
};

type SlashCommandInputTarget = 'chat' | 'planRefine';

/** Segment colors of the stacked workspace provisioning bar (cycled per environment). */
const WORKSPACE_PROGRESS_SEGMENT_VARIANTS: FpcProgressVariant[] = [
'primary',
Expand Down Expand Up @@ -858,20 +860,20 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
/** Last reply error keyed by question id. */
readonly questionReplyErrors = signal<ReadonlyMap<string, string>>(new Map());
selectedChatModel = signal<string | null>(null);
/** Marked slash command (OpenCode-style); prepended on send. */
selectedCommand = signal<string | null>(null);
/** Dismiss typeahead without clearing the `/` fragment (Escape). */
private readonly slashMenuDismissed = signal(false);
private readonly slashCommandTarget = signal<SlashCommandInputTarget>('chat');
slashHighlightIndex = signal(0);
selectedAgentId = signal<string | null>(null);

/** Token after leading `/` while composing a slash command (`null` = menu inactive). */
readonly slashCommandQuery = computed(() => {
if (this.selectedCommand() || this.slashMenuDismissed()) {
if (this.slashMenuDismissed()) {
return null;
}

const match = this.chatMessage().match(/^\/([^\s\n]*)$/);
const value = this.slashCommandTarget() === 'planRefine' ? this.planRefineMessage() : this.chatMessage();
const match = value.match(/^\/([^\s\n]*)$/);

return match ? match[1] : null;
});
Expand Down Expand Up @@ -3037,13 +3039,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
}

onSendMessage(): void {
let message = this.chatMessage().trim();
// Append selected command to message if one is selected
const selectedCmd = this.selectedCommand();

if (selectedCmd) {
message = message ? `${selectedCmd}\n${message}` : selectedCmd;
}
const message = this.chatMessage().trim();

if (!message) {
return;
Expand Down Expand Up @@ -3071,8 +3067,6 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
this.lastUserMessageTimestamp.set(Date.now());

this.chatMessage.set('');
// Clear selected command after sending
this.selectedCommand.set(null);
// Trigger scroll after sending message
this.stickToBottom.set(true);
this.shouldScrollToBottom = true;
Expand All @@ -3083,7 +3077,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
}

onEnhanceMessage(): void {
this.enhanceText(this.chatMessage(), 'chat', true);
this.enhanceText(this.chatMessage(), 'chat');
}

clearPlanRefineEnhanceError(): void {
Expand All @@ -3094,13 +3088,8 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
this.enhanceText(this.planRefineMessage(), 'planRefine');
}

private enhanceText(rawMessage: string, target: 'chat' | 'planRefine', includeSlashCommand = false): void {
let message = rawMessage.trim();
const selectedCmd = this.selectedCommand();

if (includeSlashCommand && selectedCmd) {
message = message ? `${selectedCmd}\n${message}` : selectedCmd;
}
private enhanceText(rawMessage: string, target: 'chat' | 'planRefine'): void {
const message = rawMessage.trim();

if (!message) {
return;
Expand Down Expand Up @@ -3128,12 +3117,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
}

onPlanMessage(): void {
let message = this.chatMessage().trim();
const selectedCmd = this.selectedCommand();

if (selectedCmd) {
message = message ? `${selectedCmd}\n${message}` : selectedCmd;
}
const message = this.chatMessage().trim();

if (!message) {
return;
Expand Down Expand Up @@ -3248,67 +3232,99 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
}

onChatInputKeydown(event: KeyboardEvent): void {
if (this.slashCommandMenuOpen()) {
if (this.handleSlashCommandKeydown(event, 'chat')) {
return;
}

if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
this.onSendMessage();
}
}

onPlanRefineInputKeydown(event: KeyboardEvent): void {
this.handleSlashCommandKeydown(event, 'planRefine');
}

private handleSlashCommandKeydown(event: KeyboardEvent, target: SlashCommandInputTarget): boolean {
this.slashCommandTarget.set(target);

if (this.isSlashCommandMenuOpenFor(target)) {
const filtered = this.filteredSlashCommands();

if (event.key === 'ArrowDown') {
event.preventDefault();
this.slashHighlightIndex.update((index) => Math.min(index + 1, filtered.length - 1));

return;
return true;
}

if (event.key === 'ArrowUp') {
event.preventDefault();
this.slashHighlightIndex.update((index) => Math.max(index - 1, 0));

return;
return true;
}

if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const command = filtered[this.slashHighlightIndex()] ?? filtered[0];

if (command) {
this.pickSlashCommand(command);
this.pickSlashCommand(command, undefined, target);
}

return;
return true;
}

if (event.key === 'Escape') {
event.preventDefault();
this.slashMenuDismissed.set(true);

return;
return true;
}
}

if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
this.onSendMessage();
}
return false;
}

onChatMessageInput(value: string): void {
this.chatMessage.set(value);
this.slashCommandTarget.set('chat');
this.slashMenuDismissed.set(false);
}

onChatMessageFocus(): void {
this.slashCommandTarget.set('chat');
}

onPlanRefineMessageInput(value: string): void {
this.planRefineMessage.set(value);
this.slashCommandTarget.set('planRefine');
this.slashMenuDismissed.set(false);
}

pickSlashCommand(command: AgentSlashCommand | string, event?: Event): void {
onPlanRefineMessageFocus(): void {
this.slashCommandTarget.set('planRefine');
}

isSlashCommandMenuOpenFor(target: SlashCommandInputTarget): boolean {
return this.slashCommandTarget() === target && this.slashCommandMenuOpen();
}

pickSlashCommand(command: AgentSlashCommand | string, event?: Event, target = this.slashCommandTarget()): void {
event?.preventDefault();
const name = typeof command === 'string' ? command : command.name;
this.selectedCommand.set(name);
this.chatMessage.set(
this.chatMessage()
.replace(/^\/[^\s\n]*/, '')
.replace(/^\n/, ''),
);
this.slashMenuDismissed.set(true);
}
const insertCommand = (value: string) => value.replace(/^\/[^\s\n]*/, `${name} `);

if (target === 'planRefine') {
this.planRefineMessage.update(insertCommand);
} else {
this.chatMessage.update(insertCommand);
}

clearSelectedCommand(): void {
this.selectedCommand.set(null);
this.slashCommandTarget.set(target);
this.slashMenuDismissed.set(true);
}

onChatModelChange(value: string): void {
Expand Down Expand Up @@ -5184,7 +5200,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe
}

formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString();
return new Date(timestamp).toLocaleString();
}

openTicketAutomationRunFromChat(payload: TicketAutomationRunChatEventPayload): void {
Expand Down
Loading