diff --git a/shared/godot/chat/chat_window.gd b/shared/godot/chat/chat_window.gd index 7e15bea..fffcefb 100644 --- a/shared/godot/chat/chat_window.gd +++ b/shared/godot/chat/chat_window.gd @@ -3,6 +3,7 @@ extends "res://src/ui/panel_window.gd" const ChatDisplayScript = preload("res://src/chat/chat_display.gd") const ChatInputScript = preload("res://src/chat/chat_input.gd") +const ConversationListScript = preload("res://src/chat/conversation_list.gd") const STATE_LABELS: Dictionary = { "idle": "", @@ -25,6 +26,7 @@ const REPLAY_EMOTIONS: Array[String] = [ var _display: VBoxContainer var _input_bar: PanelContainer var _status_label: Label +var _conversation_list: VBoxContainer var _miku_streaming: bool = false var _replay_regex: RegEx @@ -62,6 +64,10 @@ func _build_ui() -> void: root.add_child(_build_title_bar()) root.add_child(_build_divider()) + _conversation_list = ConversationListScript.new() + _conversation_list.setup() + root.add_child(_conversation_list) + # Message display — single RichTextLabel with native scrolling and selection var display_margin := MarginContainer.new() display_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -90,7 +96,28 @@ func _build_title_bar() -> Control: _status_label.text = "" _status_label.add_theme_color_override("font_color", TEXT_MUTED) _status_label.add_theme_font_size_override("font_size", 11) - return _build_panel_title_bar("✦ MIKU", [_status_label]) + + var list_btn := Button.new() + list_btn.text = "☰" + list_btn.flat = true + list_btn.tooltip_text = "Conversations" + list_btn.custom_minimum_size = Vector2(28, 28) + list_btn.add_theme_color_override("font_color", TEXT_MUTED) + list_btn.add_theme_color_override("font_hover_color", MIKU_TEAL) + list_btn.add_theme_font_size_override("font_size", 15) + list_btn.pressed.connect(_on_list_toggle) + + var new_btn := Button.new() + new_btn.text = "+" + new_btn.flat = true + new_btn.tooltip_text = "New conversation" + new_btn.custom_minimum_size = Vector2(28, 28) + new_btn.add_theme_color_override("font_color", TEXT_MUTED) + new_btn.add_theme_color_override("font_hover_color", MIKU_TEAL) + new_btn.add_theme_font_size_override("font_size", 18) + new_btn.pressed.connect(_on_new_conversation) + + return _build_panel_title_bar("✦ MIKU", [_status_label, list_btn, new_btn]) func replay_messages(messages: Array[Dictionary]) -> void: @@ -119,6 +146,15 @@ func show_error(message: String) -> void: _display.add_error_message(message) +func _on_new_conversation() -> void: + _conversation_list.collapse() + EventBus.conversation_new_requested.emit() + + +func _on_list_toggle() -> void: + _conversation_list.toggle() + + func _on_input_message(text: String) -> void: EventBus.text_submitted.emit(text) diff --git a/shared/godot/chat/conversation_list.gd b/shared/godot/chat/conversation_list.gd new file mode 100644 index 0000000..5bff870 --- /dev/null +++ b/shared/godot/chat/conversation_list.gd @@ -0,0 +1,156 @@ +extends VBoxContainer +## Collapsible conversation history list for the chat window. +## Shows recent conversations, highlights the active one, emits switch signals. + +const BG_DARK := Color("#0D1117") +const BG_PANEL := Color("#111822") +const BG_HOVER := Color("#162230") +const MIKU_TEAL := Color("#39C5BB") +const TEXT_PRIMARY := Color("#E8F4F3") +const TEXT_MUTED := Color("#6B8E8B") +const BORDER_COLOR := Color("#1A3330") + +const MAX_VISIBLE: int = 10 + +var _item_container: VBoxContainer +var _expanded: bool = false + + +func setup() -> void: + visible = false + size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var wrapper := PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = BG_PANEL + style.set_border_width_all(1) + style.border_color = BORDER_COLOR + style.content_margin_left = 6 + style.content_margin_right = 6 + style.content_margin_top = 6 + style.content_margin_bottom = 6 + wrapper.add_theme_stylebox_override("panel", style) + add_child(wrapper) + + var scroll := ScrollContainer.new() + scroll.custom_minimum_size.y = 0 + scroll.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + wrapper.add_child(scroll) + + _item_container = VBoxContainer.new() + _item_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _item_container.add_theme_constant_override("separation", 2) + scroll.add_child(_item_container) + + EventBus.conversation_changed.connect(_on_conversation_changed) + + +func toggle() -> void: + if _expanded: + collapse() + else: + expand() + + +func expand() -> void: + _expanded = true + refresh() + visible = true + + +func collapse() -> void: + _expanded = false + visible = false + + +func refresh() -> void: + for child: Node in _item_container.get_children(): + child.queue_free() + + var conversations: Array[Dictionary] = _get_conversations() + var active_id: String = _get_active_id() + + if conversations.is_empty(): + var empty_label := Label.new() + empty_label.text = "No conversations yet" + empty_label.add_theme_color_override("font_color", TEXT_MUTED) + empty_label.add_theme_font_size_override("font_size", 11) + _item_container.add_child(empty_label) + return + + var count: int = 0 + for conv: Dictionary in conversations: + if count >= MAX_VISIBLE: + break + var id: String = conv.get("id", "") + var title: String = conv.get("title", "Untitled") + var msg_count: int = conv.get("message_count", 0) + var is_active: bool = id == active_id + + _item_container.add_child(_build_item(id, title, msg_count, is_active)) + count += 1 + + +func _build_item( + id: String, + title: String, + msg_count: int, + is_active: bool, +) -> Control: + var btn := Button.new() + btn.flat = true + btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + btn.custom_minimum_size.y = 32 + + var display_title := title if not title.is_empty() else "New conversation" + var suffix := " (%d)" % msg_count if msg_count > 0 else "" + btn.text = display_title + suffix + + if is_active: + btn.add_theme_color_override("font_color", MIKU_TEAL) + btn.add_theme_color_override("font_hover_color", MIKU_TEAL) + else: + btn.add_theme_color_override("font_color", TEXT_PRIMARY) + btn.add_theme_color_override("font_hover_color", MIKU_TEAL) + + btn.add_theme_font_size_override("font_size", 12) + + var hover_style := StyleBoxFlat.new() + hover_style.bg_color = BG_HOVER + hover_style.set_corner_radius_all(4) + btn.add_theme_stylebox_override("hover", hover_style) + + var normal_style := StyleBoxEmpty.new() + btn.add_theme_stylebox_override("normal", normal_style) + btn.add_theme_stylebox_override("pressed", hover_style) + btn.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + + if not is_active: + btn.pressed.connect(_on_item_pressed.bind(id)) + + return btn + + +func _on_item_pressed(id: String) -> void: + EventBus.conversation_switch_requested.emit(id) + collapse() + + +func _on_conversation_changed(_id: String) -> void: + if _expanded: + refresh() + + +func _get_conversations() -> Array[Dictionary]: + var index: Dictionary = AppState.get_section("conversations") + var list: Array[Dictionary] = [] + for entry: Dictionary in index.get("list", []): + list.append(entry) + return list + + +func _get_active_id() -> String: + var index: Dictionary = AppState.get_section("conversations") + return index.get("active_id", "") diff --git a/shared/godot/chat/conversation_list.gd.uid b/shared/godot/chat/conversation_list.gd.uid new file mode 100644 index 0000000..887679c --- /dev/null +++ b/shared/godot/chat/conversation_list.gd.uid @@ -0,0 +1 @@ +uid://21napfou80ww