diff --git a/README.md b/README.md
index 940479b..4850360 100644
--- a/README.md
+++ b/README.md
@@ -1083,3 +1083,6 @@ MIT License
- vLLM OpenAI-compatible 요청의 `max_tokens`는 서버 허용 범위를 넘지 않도록 자동 보정했다. 일반 대화와 도구 호출 모두 같은 상한 계산을 써 `invalid max_tokens` 오류가 덜 나도록 맞췄다.
- 업데이트: 2026-04-06 00:48 (KST)
- AX Agent 새 대화 전환 시 저장되지 않은 fresh conversation이 최신 저장 대화로 다시 교체되던 세션 복원 경로를 보정했다. [ChatSessionStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatSessionStateService.cs) 의 `LoadOrCreateConversation()`이 기억된 대화 ID가 없는 상태에서도 현재 탭의 임시 fresh conversation을 우선 유지하도록 바꿔, 새 대화를 누르면 빈 화면이 잠깐 깜빡인 뒤 기존 대화가 다시 나타나던 문제를 막았다.
+- 업데이트: 2026-04-06 01:00 (KST)
+- AX Agent 메시지 hover 액션을 보강해 복사/편집/재생성/수정 후 재시도/좋아요·싫어요가 실제로 보이도록 정리했다. 사용자/assistant 메시지 액션 바를 완전 숨김 대신 기본 저강도 노출 + hover 강조 방식으로 바꿔, 마우스를 올렸을 때 액션이 안 보이던 문제를 줄였다.
+- assistant 응답에는 응답시간과 총 토큰 수를 메시지 메타로 저장해 transcript 아래에 함께 표시되게 했다. 반영 위치는 [ChatModels.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/ChatModels.cs), [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs), [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 이다.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index a4b56b5..b968923 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -4841,3 +4841,8 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- 업데이트: 2026-04-06 00:48 (KST)
- [ChatSessionStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ChatSessionStateService.cs) 의 `LoadOrCreateConversation()`에 현재 탭의 저장되지 않은 fresh conversation 우선 유지 분기를 추가했다. 새 대화를 시작한 직후처럼 `RememberConversation(tab, null)` 상태에서 다시 로드 경로가 돌면, 이전에는 최신 저장 대화를 다시 불러와 기존 transcript가 복원될 수 있었다.
- 이제 현재 세션에 같은 탭의 비저장 fresh conversation이 남아 있으면 최신 저장 meta fallback보다 그 임시 conversation을 먼저 반환한다. 이 변경으로 AX Agent에서 `새 대화` 클릭 시 빈 화면이 잠깐 깜빡인 뒤 기존 대화가 다시 나타나던 문제가 줄어들도록 보정했다.
+- 업데이트: 2026-04-06 01:00 (KST)
+ - [ChatModels.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/ChatModels.cs) 의 `ChatMessage`에 `responseElapsedMs`, `promptTokens`, `completionTokens` 저장 필드를 추가했다. 이제 assistant 응답별 응답시간과 토큰 사용량을 메시지 자체에 묶어 저장할 수 있다.
+ - [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 의 `CommitAssistantMessage()` / `FinalizeAssistantTurn()`은 응답 메타를 함께 커밋하도록 확장했다.
+ - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 에서는 실행 완료 시 `_llm.LastTokenUsage`, 누적 에이전트 토큰, 응답 경과 시간을 assistant 메시지에 저장하고, transcript 렌더 시 `응답시간 · 총 토큰` 메타를 assistant bubble 아래에 함께 표시하도록 연결했다.
+ - 사용자/assistant 메시지 액션 바는 완전 숨김(Opacity 0) 대신 기본 저강도 노출 + hover 시 100% 강조로 바꿨다. 이로써 메시지에 마우스를 올렸을 때 복사/편집/재생성/수정 후 재시도/좋아요·싫어요가 보이지 않던 UX 문제를 줄였다.
diff --git a/src/AxCopilot/Models/ChatModels.cs b/src/AxCopilot/Models/ChatModels.cs
index 34f1b32..1417c6e 100644
--- a/src/AxCopilot/Models/ChatModels.cs
+++ b/src/AxCopilot/Models/ChatModels.cs
@@ -262,6 +262,15 @@ public class ChatMessage
[JsonPropertyName("feedback")]
public string? Feedback { get; set; }
+ [JsonPropertyName("responseElapsedMs")]
+ public long? ResponseElapsedMs { get; set; }
+
+ [JsonPropertyName("promptTokens")]
+ public int PromptTokens { get; set; }
+
+ [JsonPropertyName("completionTokens")]
+ public int CompletionTokens { get; set; }
+
/// 첨부된 파일 경로 목록.
[JsonPropertyName("attachedFiles")]
public List? AttachedFiles { get; set; }
diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs
index 56a0597..1babf37 100644
--- a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs
+++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs
@@ -129,12 +129,20 @@ public sealed class AxAgentExecutionEngine
ChatConversation conversation,
string tab,
string content,
+ int promptTokens = 0,
+ int completionTokens = 0,
+ long? responseElapsedMs = null,
+ string? metaRunId = null,
ChatStorageService? storage = null)
{
var assistant = new ChatMessage
{
Role = "assistant",
Content = content,
+ PromptTokens = Math.Max(0, promptTokens),
+ CompletionTokens = Math.Max(0, completionTokens),
+ ResponseElapsedMs = responseElapsedMs is > 0 ? responseElapsedMs : null,
+ MetaRunId = string.IsNullOrWhiteSpace(metaRunId) ? null : metaRunId,
};
if (session != null)
@@ -153,13 +161,17 @@ public sealed class AxAgentExecutionEngine
ChatConversation conversation,
string tab,
string? content,
+ int promptTokens = 0,
+ int completionTokens = 0,
+ long? responseElapsedMs = null,
+ string? metaRunId = null,
ChatStorageService? storage = null)
{
var normalized = NormalizeAssistantContentForUi(conversation, tab, content);
if (tab is "Cowork" or "Code")
conversation.ShowExecutionHistory = false;
- CommitAssistantMessage(session, conversation, tab, normalized, storage);
+ CommitAssistantMessage(session, conversation, tab, normalized, promptTokens, completionTokens, responseElapsedMs, metaRunId, storage);
return normalized;
}
diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs
index ba1c51e..a42529c 100644
--- a/src/AxCopilot/Views/ChatWindow.xaml.cs
+++ b/src/AxCopilot/Views/ChatWindow.xaml.cs
@@ -4189,7 +4189,7 @@ public partial class ChatWindow : Window
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
- Opacity = 0,
+ Opacity = 0.8,
Margin = new Thickness(0, 2, 0, 0),
};
var capturedUserContent = content;
@@ -4215,8 +4215,8 @@ public partial class ChatWindow : Window
});
userBottomBar.Children.Add(userActionBar);
wrapper.Children.Add(userBottomBar);
- wrapper.MouseEnter += (_, _) => ShowMessageActionBar(userActionBar);
- wrapper.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(userActionBar);
+ wrapper.MouseEnter += (_, _) => userActionBar.Opacity = 1;
+ wrapper.MouseLeave += (_, _) => userActionBar.Opacity = ReferenceEquals(_selectedMessageActionBar, userActionBar) ? 1 : 0.8;
wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble);
// 우클릭 → 메시지 컨텍스트 메뉴
@@ -4412,7 +4412,7 @@ public partial class ChatWindow : Window
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(2, 2, 0, 0),
- Opacity = 0
+ Opacity = 0.8
};
var btnColor = secondaryText;
@@ -4438,8 +4438,12 @@ public partial class ChatWindow : Window
});
container.Children.Add(actionBar);
- container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar);
- container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar);
+ var assistantMeta = CreateAssistantMessageMetaText(message);
+ if (assistantMeta != null)
+ container.Children.Add(assistantMeta);
+
+ container.MouseEnter += (_, _) => actionBar.Opacity = 1;
+ container.MouseLeave += (_, _) => actionBar.Opacity = ReferenceEquals(_selectedMessageActionBar, actionBar) ? 1 : 0.8;
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard);
// 우클릭 → 메시지 컨텍스트 메뉴
@@ -4846,6 +4850,40 @@ public partial class ChatWindow : Window
actionBar.Children.Add(dislikeBtn);
}
+ private TextBlock? CreateAssistantMessageMetaText(ChatMessage? message)
+ {
+ if (message == null)
+ return null;
+
+ var parts = new List();
+ if (message.ResponseElapsedMs is > 0)
+ {
+ var elapsedMs = message.ResponseElapsedMs.Value;
+ parts.Add(elapsedMs < 1000
+ ? $"{elapsedMs}ms"
+ : $"{(elapsedMs / 1000.0):0.0}s");
+ }
+
+ if (message.PromptTokens > 0 || message.CompletionTokens > 0)
+ {
+ var totalTokens = message.PromptTokens + message.CompletionTokens;
+ parts.Add($"{FormatTokenCount(totalTokens)} tokens");
+ }
+
+ if (parts.Count == 0)
+ return null;
+
+ return new TextBlock
+ {
+ Text = string.Join(" · ", parts),
+ FontSize = 9.75,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(4, 2, 0, 0),
+ Opacity = 0.72,
+ };
+ }
+
// ─── 메시지 등장 애니메이션 ──────────────────────────────────────────
private static void ApplyMessageEntryAnimation(FrameworkElement element)
@@ -6623,7 +6661,7 @@ public partial class ChatWindow : Window
lock (_convLock)
{
var session = ChatSession;
- _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, _storage);
+ _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
_currentConversation = session?.CurrentConversation ?? conv;
conv = _currentConversation!;
}
@@ -6658,14 +6696,14 @@ public partial class ChatWindow : Window
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
- _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, _storage);
+ _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
- _chatEngine.CommitAssistantMessage(null, conv, runTab, assistantText, _storage);
+ _chatEngine.CommitAssistantMessage(null, conv, runTab, assistantText, storage: _storage);
}
}
@@ -8343,6 +8381,10 @@ public partial class ChatWindow : Window
var draftSucceeded = false;
var draftCancelled = false;
string? draftFailure = null;
+ var responseElapsedMs = 0L;
+ var promptTokens = 0;
+ var completionTokens = 0;
+ string? assistantMetaRunId = null;
_activeStreamText = null;
_cachedStreamContent = "";
@@ -8361,6 +8403,22 @@ public partial class ChatWindow : Window
(messages, token) => _llm.SendAsync(messages.ToList(), token),
_streamCts.Token);
assistantContent = response;
+ responseElapsedMs = Math.Max(0, (long)(DateTime.UtcNow - _streamStartTime).TotalMilliseconds);
+ assistantMetaRunId = _appState.AgentRun.RunId;
+ var usage = _llm.LastTokenUsage;
+ if (usage != null)
+ {
+ if (runTab is "Cowork" or "Code" && (_agentCumulativeInputTokens > 0 || _agentCumulativeOutputTokens > 0))
+ {
+ promptTokens = Math.Max(0, _agentCumulativeInputTokens);
+ completionTokens = Math.Max(0, _agentCumulativeOutputTokens);
+ }
+ else
+ {
+ promptTokens = Math.Max(0, usage.PromptTokens);
+ completionTokens = Math.Max(0, usage.CompletionTokens);
+ }
+ }
StopAiIconPulse();
_cachedStreamContent = response;
draftSucceeded = true;
@@ -8387,7 +8445,16 @@ public partial class ChatWindow : Window
lock (_convLock)
{
var session = ChatSession;
- assistantContent = _chatEngine.FinalizeAssistantTurn(session, conversation, runTab, assistantContent, _storage);
+ assistantContent = _chatEngine.FinalizeAssistantTurn(
+ session,
+ conversation,
+ runTab,
+ assistantContent,
+ promptTokens,
+ completionTokens,
+ responseElapsedMs,
+ assistantMetaRunId,
+ _storage);
_currentConversation = session?.CurrentConversation ?? conversation;
conversation = _currentConversation!;
}