feat: TaskScheduler - frontend tabbed display chats tasks

Rafael Uzarowski committed Mar 22, 2025 at 14:30 UTC 525c1c13f8516f3b210330d5f68fe472e3bd8c95
8 files changed +966 -117
python/api/poll.py
+35 -12
@@ -3,6 +3,9 @@ from flask import Request, Response
3
4 from agent import AgentContext
5
6 +from python.helpers import persist_chat
7 +
8 +
9 class Poll(ApiHandler):
10 async def process(self, input: dict, request: Request) -> dict | Response:
11 ctxid = input.get("context", None)
@@ -15,27 +18,47 @@ class Poll(ApiHandler):
18
19 # loop AgentContext._contexts
20 ctxs = []
21 + tasks = []
22 + processed_contexts = set() # Track processed context IDs
23 +
24 + # First, identify all tasks
25 for ctx in AgentContext._contexts.values():
19 - ctxs.append(
20 - {
21 - "id": ctx.id,
22 - "name": ctx.name,
23 - "no": ctx.no,
24 - "log_guid": ctx.log.guid,
25 - "log_version": len(ctx.log.updates),
26 - "log_length": len(ctx.log.logs),
27 - "paused": ctx.paused,
28 - }
29 - )
26 + # Skip if already processed
27 + if ctx.id in processed_contexts:
28 + continue
29 +
30 + context_data = {
31 + "id": ctx.id,
32 + "name": ctx.name,
33 + "no": ctx.no,
34 + "log_guid": ctx.log.guid,
35 + "log_version": len(ctx.log.updates),
36 + "log_length": len(ctx.log.logs),
37 + "paused": ctx.paused,
38 + }
39 +
40 + # Determine if this is a task using multiple methods
41 + ctx_path = persist_chat.get_chat_folder_path(ctx.id)
42 + is_task = (ctx_path and persist_chat.TASKS_FOLDER in ctx_path)
43 +
44 + # Add to the appropriate list
45 + if is_task:
46 + tasks.append(context_data)
47 + else:
48 + ctxs.append(context_data)
49 +
50 + # Mark as processed
51 + processed_contexts.add(ctx.id)
52
53 # data from this server
54 return {
55 "context": context.id,
56 "contexts": ctxs,
57 + "tasks": tasks,
58 "logs": logs,
59 "log_guid": context.log.guid,
60 "log_version": len(context.log.updates),
61 "log_progress": context.log.progress,
62 "log_progress_active": context.log.progress_active,
63 "paused": context.paused,
41 - }
\ No newline at end of file
64 + }
python/helpers/chat_names.py new
+137
@@ -0,0 +1,137 @@
1 +import json
2 +import os
3 +import time
4 +from typing import Dict, Any
5 +
6 +from python.helpers import files
7 +
8 +
9 +class ChatNames:
10 + _instance = None
11 + _names_file = "chat_names.json"
12 + _metadata_file = "chat_metadata.json"
13 +
14 + @classmethod
15 + def get_instance(cls):
16 + if cls._instance is None:
17 + cls._instance = ChatNames()
18 + cls._instance._names_file = files.get_abs_path("tmp") + "/" + cls._names_file
19 + cls._instance._metadata_file = files.get_abs_path("tmp") + "/" + cls._metadata_file
20 + return cls._instance
21 +
22 + def _read_names(self) -> dict:
23 + """Read current names from file"""
24 + try:
25 + if os.path.exists(self._names_file):
26 + with open(self._names_file, 'r') as f:
27 + return json.load(f)
28 + except Exception as e:
29 + print(f"Error reading chat names: {e}")
30 + return {}
31 +
32 + def _save_names(self, names: dict):
33 + """Save names to file"""
34 + try:
35 + with open(self._names_file, 'w') as f:
36 + json.dump(names, f, indent=2)
37 + except Exception as e:
38 + print(f"Error saving chat names: {e}")
39 +
40 + def _read_metadata(self) -> dict:
41 + """Read context metadata from file"""
42 + try:
43 + if os.path.exists(self._metadata_file):
44 + with open(self._metadata_file, 'r') as f:
45 + return json.load(f)
46 + except Exception as e:
47 + print(f"Error reading context metadata: {e}")
48 + return {}
49 +
50 + def _save_metadata(self, metadata: dict):
51 + """Save metadata to file"""
52 + try:
53 + with open(self._metadata_file, 'w') as f:
54 + json.dump(metadata, f, indent=2)
55 + except Exception as e:
56 + print(f"Error saving context metadata: {e}")
57 +
58 + def set_name(self, chat_id: str, name: str):
59 + """Set name for a chat/task"""
60 + names = self._read_names()
61 + names[chat_id] = name
62 + self._save_names(names)
63 +
64 + # Also update the name in metadata if it exists
65 + self.update_metadata(chat_id, {"name": name})
66 +
67 + def get_name(self, chat_id: str) -> str:
68 + """Get name for a chat/task"""
69 + names = self._read_names()
70 + return names.get(chat_id, f"Chat #{chat_id[:8]}")
71 +
72 + def set_metadata(self, chat_id: str, metadata_dict: Dict[str, Any]):
73 + """Set complete metadata for a chat/task"""
74 + all_metadata = self._read_metadata()
75 +
76 + # Ensure we have created_at timestamp if not provided
77 + if "created_at" not in metadata_dict:
78 + metadata_dict["created_at"] = int(time.time())
79 +
80 + all_metadata[chat_id] = metadata_dict
81 + self._save_metadata(all_metadata)
82 +
83 + # Also update the name in the names file for backward compatibility
84 + if "name" in metadata_dict:
85 + self.set_name(chat_id, metadata_dict["name"])
86 +
87 + def update_metadata(self, chat_id: str, metadata_update: Dict[str, Any]):
88 + """Update specific metadata fields for a chat/task"""
89 + all_metadata = self._read_metadata()
90 +
91 + if chat_id in all_metadata:
92 + all_metadata[chat_id].update(metadata_update)
93 + else:
94 + # Initialize with current timestamp if creating new metadata
95 + metadata_update["created_at"] = metadata_update.get("created_at", int(time.time()))
96 + all_metadata[chat_id] = metadata_update
97 +
98 + self._save_metadata(all_metadata)
99 +
100 + def get_metadata(self, chat_id: str) -> Dict[str, Any]:
101 + """Get metadata for a chat/task"""
102 + all_metadata = self._read_metadata()
103 +
104 + # Return metadata if it exists
105 + if chat_id in all_metadata:
106 + return all_metadata[chat_id]
107 +
108 + # Otherwise create default metadata with just the name
109 + name = self.get_name(chat_id)
110 + default_metadata = {
111 + "name": name,
112 + "created_at": int(time.time()) # Current time as fallback
113 + }
114 + return default_metadata
115 +
116 + def get_created_at(self, chat_id: str) -> int:
117 + """Get creation timestamp for a chat/task"""
118 + metadata = self.get_metadata(chat_id)
119 + return metadata.get("created_at", 0)
120 +
121 + def remove_chat(self, chat_id: str):
122 + """Remove chat/task and its metadata"""
123 + print("Removing chat name and metadata: ", chat_id)
124 +
125 + # Remove from names file
126 + names = self._read_names()
127 + if chat_id in names:
128 + del names[chat_id]
129 + print("Chat name removed: ", chat_id)
130 + self._save_names(names)
131 +
132 + # Remove from metadata file
133 + metadata = self._read_metadata()
134 + if chat_id in metadata:
135 + del metadata[chat_id]
136 + print("Chat metadata removed: ", chat_id)
137 + self._save_metadata(metadata)
python/helpers/persist_chat.py
+33 -15
@@ -9,27 +9,30 @@ from initialize import initialize
9 from python.helpers.log import Log, LogItem
10
11 CHATS_FOLDER = "tmp/chats"
12 +TASKS_FOLDER = "tmp/task_chats"
13 LOG_SIZE = 1000
14 CHAT_FILE_NAME = "chat.json"
15
16
16 -def get_chat_folder_path(ctxid: str):
17 - return files.get_abs_path(CHATS_FOLDER, ctxid)
17 +def get_chat_folder_path(ctxid: str, folder: str = CHATS_FOLDER):
18 + return files.get_abs_path(folder, ctxid)
19
19 -def save_tmp_chat(context: AgentContext):
20 - path = _get_chat_file_path(context.id)
20 +
21 +def save_tmp_chat(context: AgentContext, folder: str = CHATS_FOLDER):
22 + path = _get_chat_file_path(context.id, folder)
23 files.make_dirs(path)
24 data = _serialize_context(context)
25 js = _safe_json_serialize(data, ensure_ascii=False)
26 files.write_file(path, js)
27
28
27 -def load_tmp_chats():
28 - _convert_v080_chats()
29 - folders = files.list_files(CHATS_FOLDER, "*")
29 +def load_tmp_chats(folder: str = CHATS_FOLDER):
30 + if folder == CHATS_FOLDER:
31 + _convert_v080_chats()
32 + folders = files.list_files(folder, "*")
33 json_files = []
31 - for folder in folders:
32 - json_files.append(_get_chat_file_path(folder))
34 + for folder_name in folders:
35 + json_files.append(_get_chat_file_path(folder_name, folder))
36
37 ctxids = []
38 for file in json_files:
@@ -43,8 +46,8 @@ def load_tmp_chats():
46 return ctxids
47
48
46 -def _get_chat_file_path(ctxid: str):
47 - return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME)
49 +def _get_chat_file_path(ctxid: str, folder: str = CHATS_FOLDER):
50 + return files.get_abs_path(folder, ctxid, CHAT_FILE_NAME)
51
52
53 def _convert_v080_chats():
@@ -52,12 +55,11 @@ def _convert_v080_chats():
55 for file in json_files:
56 path = files.get_abs_path(CHATS_FOLDER, file)
57 name = file.rstrip(".json")
55 - fold = files.get_abs_path(CHATS_FOLDER, name)
58 new = _get_chat_file_path(name)
59 files.move_file(path, new)
60
61
60 -def load_json_chats(jsons: list[str]):
62 +def load_json_chats(jsons: list[str], folder: str = CHATS_FOLDER):
63 ctxids = []
64 for js in jsons:
65 data = json.loads(js)
@@ -74,9 +76,25 @@ def export_json_chat(context: AgentContext):
76 return js
77
78
77 -def remove_chat(ctxid):
78 - files.delete_dir(get_chat_folder_path(ctxid))
79 +def remove_chat(ctxid, folder: str = CHATS_FOLDER):
80 + files.delete_dir(get_chat_folder_path(ctxid, folder))
81 +
82 +
83 +# Task-specific functions for convenience
84 +def save_tmp_task(context: AgentContext):
85 + save_tmp_chat(context, TASKS_FOLDER)
86 +
87 +
88 +def load_tmp_tasks():
89 + return load_tmp_chats(TASKS_FOLDER)
90 +
91 +
92 +def load_json_tasks(jsons: list[str]):
93 + return load_json_chats(jsons, TASKS_FOLDER)
94 +
95
96 +def remove_task(ctxid):
97 + remove_chat(ctxid, TASKS_FOLDER)
98
99
100 def _serialize_context(context: AgentContext):
python/helpers/task_scheduler.py
+34 -24
@@ -14,14 +14,13 @@ from pydantic import BaseModel, Field, PrivateAttr
14 from python.helpers.files import get_abs_path, exists, write_file, read_file, make_dirs
15 from agent import Agent, AgentContext, UserMessage
16 from initialize import initialize
17 -from python.helpers.persist_chat import export_json_chat, load_json_chats
17 +from python.helpers.persist_chat import export_json_chat, load_json_chats, load_tmp_chats, save_tmp_chat
18 from python.helpers.print_style import PrintStyle
19 from python.helpers.defer import DeferredTask
20 -from python.helpers.persist_chat import CHATS_FOLDER
20 +from python.helpers.persist_chat import CHATS_FOLDER, TASKS_FOLDER
21 from python.helpers import errors
22
23 SCHEDULER_FOLDER = "memory/scheduler"
24 -TASKS_FOLDER = CHATS_FOLDER
24
25
26 class TaskSchedule(BaseModel):
@@ -37,7 +36,7 @@ class TaskSchedule(BaseModel):
36
37 class AdHocTask(BaseModel):
38 uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
40 - state: Literal["idle", "running"] = Field(default="idle")
39 + state: Literal["idle", "running", "disabled"] = Field(default="idle")
40 name: str = Field()
41 system_prompt: str
42 prompt: str
@@ -71,7 +70,7 @@ class AdHocTask(BaseModel):
70
71 def update(self,
72 name: str | None = None,
74 - state: Literal["idle", "running"] | None = None,
73 + state: Literal["idle", "running", "disabled"] | None = None,
74 system_prompt: str | None = None,
75 prompt: str | None = None,
76 attachments: list[str] | None = None,
@@ -107,7 +106,7 @@ class AdHocTask(BaseModel):
106
107 class ScheduledTask(BaseModel):
108 uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
110 - state: Literal["idle", "running"] = Field(default="idle")
109 + state: Literal["idle", "running", "disabled"] = Field(default="idle")
110 name: str
111 schedule: TaskSchedule
112 system_prompt: str
@@ -141,7 +140,7 @@ class ScheduledTask(BaseModel):
140
141 def update(self,
142 name: str | None = None,
144 - state: Literal["idle", "running"] | None = None,
143 + state: Literal["idle", "running", "disabled"] | None = None,
144 system_prompt: str | None = None,
145 prompt: str | None = None,
146 attachments: list[str] | None = None,
@@ -277,44 +276,55 @@ class TaskScheduler:
276 config = initialize()
277 context: AgentContext = AgentContext(config)
278 context.id = task.uuid
280 - chat_json = export_json_chat(context)
281 - chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
282 - make_dirs(chat_file)
283 - write_file(chat_file, chat_json)
279 + # chat_json = export_json_chat(context)
280 + # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
281 + # make_dirs(chat_file)
282 + # write_file(chat_file, chat_json)
283 + save_tmp_chat(context, TASKS_FOLDER)
284 return context
285
286 async def _get_chat_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext:
287 - chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
288 - if exists(chat_file):
289 - chat = read_file(chat_file)
290 - context = AgentContext.get(load_json_chats([chat])[0])
287 + ctxids = load_tmp_chats(TASKS_FOLDER)
288 + # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
289 + # if exists(chat_file):
290 + if task.uuid in ctxids:
291 + # chat = read_file(chat_file)
292 + context = AgentContext.get(task.uuid)
293 if isinstance(context, AgentContext):
294 self._printer.print(
293 - f"Scheduler Task {task.name} loaded from chat {task.uuid}"
295 + f"Scheduler Task {task.name} loaded from task {task.uuid}, context ok"
296 )
297 + context.id = task.uuid
298 + save_tmp_chat(context, TASKS_FOLDER)
299 return context
300 else:
301 self._printer.print(
298 - f"Scheduler Task {task.name} loaded from chat {task.uuid} but failed to load context"
302 + f"Scheduler Task {task.name} loaded from task {task.uuid} but failed to load context"
303 )
304 return await self.__new_context(task)
305 else:
306 self._printer.print(
303 - f"Scheduler Task {task.name} loaded from chat {task.uuid} but chat file not found"
307 + f"Scheduler Task {task.name} loaded from task {task.uuid} but context not found"
308 )
309 return await self.__new_context(task)
310
311 async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask], context: AgentContext):
308 - chat_json = export_json_chat(context)
309 - chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
310 - make_dirs(chat_file)
311 - write_file(chat_file, chat_json)
312 + # chat_json = export_json_chat(context)
313 + # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
314 + # make_dirs(chat_file)
315 + # write_file(chat_file, chat_json)
316 + context.id = task.uuid
317 + save_tmp_chat(context, TASKS_FOLDER)
318
319 async def _run_task(self, task: Union[ScheduledTask, AdHocTask]):
320
321 async def _run_task_wrapper(task: Union[ScheduledTask, AdHocTask]):
322 + if task.state != "idle":
323 + self._printer.print(f"Scheduler Task {task.name} state is '{task.state}', skipping")
324 + return
325 +
326 if task.state == "running":
317 - self._printer.print(f"Scheduler Task {task.name} already running")
327 + self._printer.print(f"Scheduler Task {task.name} already running, skipping")
328 return
329
330 try:
@@ -359,7 +369,7 @@ class TaskScheduler:
369 result = await agent.monologue()
370 task.update(last_result="SUCCESS: " + result)
371
362 - self._printer.print(f"Scheduler Task {task.name} completed: {result}")
372 + self._printer.print(f"Scheduler Task '{task.name}' completed: {result}")
373
374 await self._persist_chat(task, context)
375
run_ui.py
+3 -1
@@ -11,7 +11,6 @@ from python.helpers.extract_tools import load_classes_from_folder
11 from python.helpers.api import ApiHandler
12 from python.helpers.print_style import PrintStyle
13 import sys
14 -import asyncio
14 import socket
15 import struct
16
@@ -168,6 +167,9 @@ def run():
167 # initialize contexts from persisted chats
168 persist_chat.load_tmp_chats()
169
170 + # initialize contexts from persisted tasks
171 + persist_chat.load_tmp_tasks()
172 +
173 except Exception as e:
174 PrintStyle().error(errors.format_error(e))
175
webui/index.css
+337 -13
@@ -208,8 +208,57 @@ body,
208 }
209
210 /* Chats container */
211 +.chat-container {
212 + display: flex;
213 + align-items: center;
214 + position: relative;
215 + width: 100%;
216 + min-height: 30px;
217 + gap: 0;
218 +}
219 +
220 .chat-list-button {
221 + display: block;
222 + width: 100%;
223 + padding: 1px 5px;
224 + cursor: pointer;
225 + overflow: hidden;
226 + position: relative;
227 + transition: background-color 0.2s;
228 + border-radius: 4px;
229 +}
230 +
231 +/* Subtle background on hover for the entire row */
232 +.chat-list-button:hover {
233 + background-color: rgba(255, 255, 255, 0.03);
234 +}
235 +
236 +.light-mode .chat-list-button:hover {
237 + background-color: rgba(0, 0, 0, 0.02);
238 +}
239 +
240 +.chat-name {
241 + display: inline-block;
242 + max-width: 160px;
243 + overflow: hidden;
244 + text-overflow: ellipsis;
245 + white-space: nowrap;
246 cursor: pointer;
247 + padding: 3px 8px;
248 + border-radius: 4px;
249 + transition: background-color 0.2s;
250 + margin-right: 60px; /* Make space for buttons */
251 + font-size: var(--font-size-small); /* Match config button font size */
252 +}
253 +
254 +/* Add a nice hover effect to just the chat name */
255 +.chat-name:hover {
256 + background-color: rgba(255, 255, 255, 0.1);
257 + text-decoration: none;
258 +}
259 +
260 +.light-mode .chat-name:hover {
261 + background-color: rgba(0, 0, 0, 0.05);
262 }
263
264 .chats-list-container {
@@ -232,12 +281,26 @@ body,
281 background: linear-gradient(to bottom, calc(100% - 20px), transparent 100%);
282 /* Add padding to account for fade */
283 padding-bottom: 20px;
235 - scrollbar-width: none;
236 - -ms-overflow-style: none;
284 + scrollbar-width: thin;
285 + -ms-overflow-style: auto;
286 }
287
288 .chats-list-container::-webkit-scrollbar {
240 - width: 0px;
289 + width: 5px;
290 +}
291 +
292 +.chats-list-container::-webkit-scrollbar-track {
293 + background: rgba(0, 0, 0, 0.2);
294 + border-radius: 6px;
295 +}
296 +
297 +.chats-list-container::-webkit-scrollbar-thumb {
298 + background-color: var(--color-border);
299 + border-radius: 6px;
300 +}
301 +
302 +.chats-list-container::-webkit-scrollbar-thumb:hover {
303 + background-color: var(--color-border);
304 }
305
306 /* Chats Section */
@@ -247,7 +310,7 @@ body,
310 flex-direction: column;
311 min-height: 0;
312 flex: 1;
250 - margin-top: 1.5rem;
313 + margin-top: 0.5rem;
314 }
315
316 /* Preferences */
@@ -1178,7 +1241,7 @@ pre {
1241 }
1242
1243 .text-button:active {
1181 - opacity: 0.5;
1244 + opacity: 0.5;
1245 }
1246
1247 .text-button svg {
@@ -1588,7 +1651,7 @@ input:checked + .slider:before {
1651 display: table;
1652 gap: 0.1rem !important;
1653 }
1591 -
1654 +
1655 .text-button {
1656 max-height: 25px;
1657 }
@@ -1603,7 +1666,7 @@ input:checked + .slider:before {
1666 margin-left: var(--spacing-md);
1667 margin-bottom: var(--spacing-md);
1668 }
1606 -
1669 +
1670 .msg-kvps {
1671 display: flex;
1672 flex-direction: column;
@@ -1669,7 +1732,7 @@ input:checked + .slider:before {
1732 .sidebar-overlay.visible {
1733 display: block;
1734 }
1672 -}
1735 +}
1736
1737 @media (max-width: 768px) {
1738 #left-panel {
@@ -1699,15 +1762,15 @@ input:checked + .slider:before {
1762 -webkit-transition: all 0.3s ease;
1763 transition: all 0.3s ease;
1764 }
1702 -
1765 +
1766 #right-panel.expanded #logo-container {
1767 margin-left: 4.6rem;
1768 }
1706 -
1769 +
1770 #input-section {
1771 align-items: start;
1772 }
1710 -
1773 +
1774 .text-buttons-row {
1775 width: 90%;
1776 display: flex;
@@ -1786,7 +1849,7 @@ input:checked + .slider:before {
1849 -webkit-font-smoothing: antialiased;
1850 -moz-osx-font-smoothing: grayscale;
1851 }
1789 -
1852 +
1853 #chats-section {
1854 min-height: 100%;
1855 }
@@ -2095,4 +2158,265 @@ a:active {
2158 /* Alpine cloak to prevent FOUC */
2159 [x-cloak] {
2160 display: none !important;
2098 -}
\ No newline at end of file
2161 +}
2162 +
2163 +/* Add new styles for reasoning and deepsearchbutton */
2164 +.ml-auto {
2165 + margin-left: auto;
2166 +}
2167 +
2168 +/* Remove unnecessary specific media query that was causing issues */
2169 +@media (max-width: 480px) {
2170 + .text-button svg {
2171 + width: 16px;
2172 + height: 16px;
2173 + }
2174 +}
2175 +
2176 +/* Add to the existing .chat-actions class or create it */
2177 +.chat-actions {
2178 + display: flex;
2179 + gap: 5px;
2180 + position: absolute;
2181 + right: 5px;
2182 + top: 50%;
2183 + transform: translateY(-50%);
2184 + z-index: 2; /* Ensure buttons are above the edit field */
2185 + min-width: 70px; /* Ensure minimum width for the buttons */
2186 + justify-content: flex-end;
2187 +}
2188 +
2189 +/* Tasks list container - similar to chats list */
2190 +.tasks-list-container {
2191 + max-height: 300px;
2192 + overflow-y: auto;
2193 + margin-top: 10px;
2194 + padding-right: 5px;
2195 + border-radius: 5px;
2196 + position: relative;
2197 + scrollbar-width: thin;
2198 + -ms-overflow-style: auto;
2199 +}
2200 +
2201 +.tasks-list-container::-webkit-scrollbar {
2202 + width: 5px;
2203 +}
2204 +
2205 +.tasks-list-container::-webkit-scrollbar-track {
2206 + background: rgba(0, 0, 0, 0.2);
2207 + border-radius: 6px;
2208 +}
2209 +
2210 +.tasks-list-container::-webkit-scrollbar-thumb {
2211 + background-color: var(--color-border);
2212 + border-radius: 6px;
2213 +}
2214 +
2215 +.tasks-list-container::-webkit-scrollbar-thumb:hover {
2216 + background-color: var(--color-border);
2217 +}
2218 +
2219 +.task-name {
2220 + display: inline-block;
2221 + max-width: 160px;
2222 + overflow: hidden;
2223 + text-overflow: ellipsis;
2224 + white-space: nowrap;
2225 + cursor: pointer;
2226 + padding: 3px 5px;
2227 + border-radius: 4px;
2228 + transition: background-color 0.2s;
2229 + margin-right: 60px; /* Make space for future buttons */
2230 + font-size: var(--font-size-small); /* Match config button font size */
2231 +}
2232 +
2233 +.task-name:hover {
2234 + background-color: rgba(255, 255, 255, 0.1);
2235 + text-decoration: none;
2236 +}
2237 +
2238 +.light-mode .task-name:hover {
2239 + background-color: rgba(0, 0, 0, 0.05);
2240 +}
2241 +
2242 +/* Dark mode overrides */
2243 +.light-mode .tab.active {
2244 + color: var(--highlight-pink);
2245 +}
2246 +
2247 +.light-mode .tab.active::after {
2248 + background-color: var(--highlight-pink);
2249 + box-shadow: 0 0 8px var(--highlight-pink);
2250 +}
2251 +/* Tabs styling */
2252 +.tabs-container {
2253 + width: 100%;
2254 + margin-bottom: 8px; /* Reduced spacing between tabs and list */
2255 + padding: 0;
2256 + margin-top: 20px; /* Increased spacing from elements above */
2257 +}
2258 +
2259 +.tabs {
2260 + display: flex;
2261 + width: 100%;
2262 + position: relative;
2263 + gap: 5px;
2264 + border-bottom: 3px solid var(--color-border); /* Thicker bottom line */
2265 + justify-content: center; /* Center the tabs */
2266 +}
2267 +
2268 +.tab {
2269 + padding: 8px 16px;
2270 + cursor: pointer;
2271 + position: relative;
2272 + color: var(--color-text);
2273 + border: 2px solid var(--color-border);
2274 + border-bottom: none;
2275 + border-radius: 8px 8px 0 0;
2276 + transition: all 0.3s ease;
2277 + background-color: var(--color-panel);
2278 + margin-bottom: -3px; /* Match the thicker border */
2279 + z-index: 1;
2280 +}
2281 +
2282 +.tab:not(.active) {
2283 + opacity: 0.8;
2284 + border-bottom: 3px solid var(--color-border);
2285 + background-color: rgba(255, 255, 255, 0.03);
2286 +}
2287 +
2288 +.tab.active {
2289 + color: var(--color-primary);
2290 + border-color: var(--color-border);
2291 + box-shadow:
2292 + 0 -4px 8px -2px var(--color-border),
2293 + 4px 0 8px -2px var(--color-border),
2294 + -4px 0 8px -2px var(--color-border);
2295 + font-weight: bold;
2296 + background-color: var(--color-panel);
2297 +}
2298 +
2299 +.light-mode .tab.active {
2300 + color: var(--color-primary);
2301 + box-shadow:
2302 + 0 -4px 8px -2px var(--color-border),
2303 + 4px 0 8px -2px var(--color-border),
2304 + -4px 0 8px -2px var(--color-border);
2305 +}
2306 +
2307 +.light-mode .tab:not(.active) {
2308 + background-color: rgba(0, 0, 0, 0.03);
2309 +}
2310 +
2311 +/* Remove previous tab styling that conflicts */
2312 +.tab.active::after {
2313 + display: none;
2314 +}
2315 +
2316 +/* Empty list message styling enhancement */
2317 +.empty-list-message {
2318 + display: flex;
2319 + justify-content: center;
2320 + align-items: center;
2321 + height: 100px;
2322 + color: var(--color-secondary);
2323 + text-align: center;
2324 + opacity: 0.7;
2325 + font-style: italic;
2326 +}
2327 +
2328 +.light-mode .empty-list-message {
2329 + color: var(--color-secondary-light);
2330 +}
2331 +
2332 +/* Common scrollbar styling */
2333 +::-webkit-scrollbar {
2334 + width: 5px;
2335 + height: 5px;
2336 +}
2337 +
2338 +::-webkit-scrollbar-track {
2339 + background: rgba(0, 0, 0, 0.2);
2340 + border-radius: 6px;
2341 +}
2342 +
2343 +::-webkit-scrollbar-thumb {
2344 + background-color: var(--color-border);
2345 + border-radius: 6px;
2346 + transition: background-color 0.2s ease;
2347 +}
2348 +
2349 +::-webkit-scrollbar-thumb:hover {
2350 + background-color: var(--color-border);
2351 +}
2352 +
2353 +::-webkit-scrollbar-thumb:active {
2354 + background-color: var(--color-border);
2355 +}
2356 +
2357 +/* Firefox scrollbar */
2358 +* {
2359 + scrollbar-width: thin;
2360 + scrollbar-color: var(--color-border) rgba(0, 0, 0, 0.2);
2361 +}
2362 +
2363 +/* Light mode scrollbar */
2364 +.light-mode ::-webkit-scrollbar-track {
2365 + background: rgba(200, 200, 200, 0.3);
2366 +}
2367 +
2368 +.light-mode ::-webkit-scrollbar-thumb {
2369 + background-color: var(--color-border);
2370 +}
2371 +
2372 +.light-mode ::-webkit-scrollbar-thumb:hover {
2373 + background-color: var(--color-border);
2374 +}
2375 +
2376 +.light-mode ::-webkit-scrollbar-thumb:active {
2377 + background-color: var(--color-border);
2378 +}
2379 +
2380 +.light-mode * {
2381 + scrollbar-color: var(--color-border) rgba(200, 200, 200, 0.3);
2382 +}
2383 +
2384 +/* Add specific styling for selected chat items */
2385 +.chat-list-button.font-bold {
2386 + position: relative;
2387 + background-color: var(--color-border) 0.05;
2388 +}
2389 +
2390 +.chat-list-button.font-bold::before {
2391 + content: '';
2392 + position: absolute;
2393 + left: 0;
2394 + top: 0;
2395 + height: 100%;
2396 + width: 3px;
2397 + background-color: var(--color-border);
2398 + border-top-left-radius: 3px;
2399 + border-bottom-left-radius: 3px;
2400 +}
2401 +
2402 +.light-mode .chat-list-button.font-bold {
2403 + background-color: var(--color-border) 0.05;
2404 +}
2405 +
2406 +.light-mode .chat-list-button.font-bold::before {
2407 + background-color: var(--color-border);
2408 +}
2409 +
2410 +/* Add some padding to the list items to accommodate the stripe */
2411 +.chat-list-button {
2412 + padding-left: 5px;
2413 + border-radius: 4px;
2414 + transition: background-color 0.2s ease-in-out;
2415 +}
2416 +
2417 +/* Make sure the chat container has proper spacing */
2418 +.chat-container, .task-container {
2419 + display: flex;
2420 + align-items: center;
2421 + width: 100%;
2422 +}
webui/index.html
+55 -20
@@ -87,22 +87,57 @@
87 </svg>Settings</button>
88
89 </div>
90 +
91 + <!-- Tabs container -->
92 + <div class="tabs-container">
93 + <div class="tabs">
94 + <div class="tab active" id="chats-tab">Chats</div>
95 + <div class="tab" id="tasks-tab">Tasks</div>
96 + </div>
97 + </div>
98 +
99 <!-- Chats List -->
100 <div class="config-section" id="chats-section" x-data="{ contexts: [], selected: '' }"
92 - x-show="contexts.length > 0">
93 - <h3>Chats</h3>
101 + x-show="contexts.length > 0 || true">
102 <div class="chats-list-container">
95 - <ul class="config-list">
103 + <ul class="config-list" x-show="contexts.length > 0">
104 <template x-for="context in contexts">
105 <li>
98 - <span :class="{'chat-list-button': true, 'font-bold': context.id === selected}"
106 + <div :class="{'chat-list-button': true, 'font-bold': context.id === selected}"
107 @click="selected = context.id; selectChat(context.id)">
100 - <span x-text="context.name ? context.name : 'Chat #' + context.no"></span>
101 - </span>
108 + <span class="chat-name" x-text="context.name ? context.name : 'Chat #' + context.no"></span>
109 + </div>
110 <button class="edit-button" @click="killChat(context.id)">X</button>
111 </li>
112 </template>
113 </ul>
114 + <div class="empty-list-message" x-show="contexts.length === 0">
115 + <p><i>No chats to list.</i></p>
116 + </div>
117 + </div>
118 + </div>
119 +
120 + <!-- Tasks List -->
121 + <div class="config-section" id="tasks-section" x-data="{ tasks: [], selected: '' }"
122 + style="display: none;">
123 + <div class="tasks-list-container">
124 + <ul class="config-list" x-show="tasks.length > 0">
125 + <template x-for="task in tasks">
126 + <li>
127 + <div :class="{'chat-list-button': true, 'font-bold': task.id === selected}"
128 + @click="selected = task.id; selectChat(task.id)">
129 + <div class="chat-container">
130 + <span class="task-name"
131 + x-text="task.name || `Task #${task.id.substring(0,8)}`"
132 + :data-task-id="task.id"></span>
133 + </div>
134 + </div>
135 + </li>
136 + </template>
137 + </ul>
138 + <div class="empty-list-message" x-show="tasks.length === 0">
139 + <p><i>No tasks to list.</i></p>
140 + </div>
141 </div>
142 </div>
143 </div>
@@ -216,19 +251,19 @@
251 <span id="stop-speech" @click="window.speech.stop()" style="cursor: pointer">Stop Speech</span>
252 </h4>
253 </div>
219 - <div id="input-section" x-data="{
254 + <div id="input-section" x-data="{
255 paused: false,
256 attachments: [],
257 hasAttachments: false,
223 -
258 +
259 handleFileUpload(event) {
260 const files = event.target.files;
226 -
261 +
262 Array.from(files).forEach(file => {
263 const ext = file.name.split('.').pop().toLowerCase();
229 -
264 +
265 const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
231 -
266 +
267 if (isImage) {
268 // Handle image preview
269 const reader = new FileReader();
@@ -298,7 +333,7 @@
333 <!-- Container for textarea and button -->
334 <div id="chat-input-container" style="position: relative;">
335 <textarea id="chat-input" placeholder="Type your message here..." rows="1"></textarea>
301 -
336 +
337 <!-- Expand button inside the textarea container -->
338 <button id="expand-button" @click="$store.fullScreenInputModal.openModal()" aria-label="Expand input">
339 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
@@ -306,7 +341,7 @@
341 </svg>
342 </button>
343 </div>
309 -
344 +
345 <div id="chat-buttons-wrapper">
346
347 <!-- Send button -->
@@ -687,12 +722,12 @@
722 <!-- Full Screen Input Modal -->
723 <div id="fullScreenInputModal" x-data="fullScreenInputModalProxy">
724 <template x-teleport="body">
690 - <div x-show="isOpen" class="modal-overlay" @click.self="handleClose()"
725 + <div x-show="isOpen" class="modal-overlay" @click.self="handleClose()"
726 @keydown.escape.window="handleClose()" x-transition>
727 <div class="modal-container full-screen-input-modal">
728 <div class="modal-content">
729 <button class="modal-close" @click="handleClose()">&times;</button>
695 -
730 +
731 <!-- Add toolbar -->
732 <div class="editor-toolbar">
733 <div class="toolbar-group">
@@ -725,8 +760,8 @@
760 </div>
761 </div>
762
728 - <textarea id="full-screen-input" x-model="inputText"
729 - placeholder="Type your message here..."
763 + <textarea id="full-screen-input" x-model="inputText"
764 + placeholder="Type your message here..."
765 @keydown.ctrl.enter="handleClose()"
766 @keydown.ctrl.z.prevent="undo()"
767 @keydown.ctrl.shift.z.prevent="redo()"
@@ -744,9 +779,9 @@
779 </div>
780
781 <!-- Drag and Drop Overlay -->
747 - <div id="dragdrop-overlay"
782 + <div id="dragdrop-overlay"
783 x-cloak
749 - x-data="{ isVisible: false }"
784 + x-data="{ isVisible: false }"
785 x-show="isVisible"
786 x-transition:enter="transition ease-out duration-300"
787 x-transition:enter-start="opacity-0"
@@ -762,4 +797,4 @@
797
798 </body>
799
765 -</html>
\ No newline at end of file
800 +</html>
webui/index.js
+332 -32
@@ -10,6 +10,7 @@ const sendButton = document.getElementById('send-button');
10 const inputSection = document.getElementById('input-section');
11 const statusSection = document.getElementById('status-section');
12 const chatsSection = document.getElementById('chats-section');
13 +const tasksSection = document.getElementById('tasks-section');
14 const progressBar = document.getElementById('progress-bar');
15 const autoScrollSwitch = document.getElementById('auto-scroll-switch');
16 const timeDate = document.getElementById('time-date-container');
@@ -20,8 +21,10 @@ let context = "";
21 let connectionStatus = false
22
23
23 -// Initialize the toggle button
24 +// Initialize the toggle button
25 setupSidebarToggle();
26 +// Initialize tabs
27 +setupTabs();
28
29 function isMobile() {
30 return window.innerWidth <= 768;
@@ -149,7 +152,7 @@ export async function sendMessage() {
152 // } else {
153 // toast("Undefined error.", "error");
154 // }
152 - // }
155 + // }
156 else {
157 setContext(jsonResponse.context);
158 }
@@ -340,7 +343,12 @@ async function poll() {
343 let updated = false
344 try {
345 const response = await sendJsonData("/poll", { log_from: lastLogVersion, context });
343 - //console.log(response)
346 +
347 + // Check if the response is valid
348 + if (!response) {
349 + console.error("Invalid response from poll endpoint");
350 + return false;
351 + }
352
353 if (!context) setContext(response.context)
354 if (response.context != context) return //skip late polls after context change
@@ -359,6 +367,9 @@ async function poll() {
367 afterMessagesUpdate(response.logs)
368 }
369
370 + lastLogVersion = response.log_version;
371 + lastLogGuid = response.log_guid;
372 +
373 updateProgress(response.log_progress, response.log_progress_active)
374
375 //set ui model vars from backend
@@ -368,11 +379,95 @@ async function poll() {
379 // Update status icon state
380 setConnectionStatus(true)
381
382 + // Update chats list and sort by created_at time (newer first)
383 const chatsAD = Alpine.$data(chatsSection);
372 - chatsAD.contexts = response.contexts;
384 + const contexts = response.contexts || [];
385 + chatsAD.contexts = contexts.sort((a, b) =>
386 + (b.created_at || 0) - (a.created_at || 0)
387 + );
388 +
389 + // Update tasks list and sort by creation time (newer first)
390 + const tasksSection = document.getElementById('tasks-section');
391 + if (tasksSection) {
392 + const tasksAD = Alpine.$data(tasksSection);
393 + let tasks = response.tasks || [];
394 +
395 + // Only update the tasks array if it's actually different
396 + // This prevents unnecessary reactivity triggers
397 + const currentTaskIds = new Set((tasksAD.tasks || []).map(t => t.id));
398 + const newTaskIds = new Set(tasks.map(t => t.id));
399 +
400 + // Check if the sets are different sizes or have different contents
401 + const needsUpdate = currentTaskIds.size !== newTaskIds.size ||
402 + tasks.some(task => !currentTaskIds.has(task.id));
403 +
404 + if (needsUpdate) {
405 + if (tasks.length > 0) {
406 + // Sort the tasks by creation time
407 + const sortedTasks = [...tasks].sort((a, b) =>
408 + (b.created_at || 0) - (a.created_at || 0)
409 + );
410 +
411 + // Use a clean array assignment to avoid duplicating elements
412 + tasksAD.tasks = sortedTasks;
413 + } else {
414 + // Make sure to use a new empty array instance
415 + tasksAD.tasks = [];
416 + }
417 + }
418 + }
419
374 - lastLogVersion = response.log_version;
375 - lastLogGuid = response.log_guid;
420 + // Make sure the active context is properly selected in both lists
421 + if (context) {
422 + // Update selection in the active tab
423 + const activeTab = localStorage.getItem('activeTab') || 'chats';
424 +
425 + if (activeTab === 'chats') {
426 + chatsAD.selected = context;
427 + localStorage.setItem('lastSelectedChat', context);
428 +
429 + // Check if this context exists in the chats list
430 + const contextExists = contexts.some(ctx => ctx.id === context);
431 +
432 + // If it doesn't exist in the chats list but we're in chats tab, try to select the first chat
433 + if (!contextExists && contexts.length > 0) {
434 + const firstChatId = contexts[0].id;
435 + setContext(firstChatId);
436 + chatsAD.selected = firstChatId;
437 + localStorage.setItem('lastSelectedChat', firstChatId);
438 + }
439 + } else if (activeTab === 'tasks' && tasksSection) {
440 + const tasksAD = Alpine.$data(tasksSection);
441 + tasksAD.selected = context;
442 + localStorage.setItem('lastSelectedTask', context);
443 +
444 + // Check if this context exists in the tasks list
445 + const taskExists = response.tasks?.some(task => task.id === context);
446 +
447 + // If it doesn't exist in the tasks list but we're in tasks tab, try to select the first task
448 + if (!taskExists && response.tasks?.length > 0) {
449 + const firstTaskId = response.tasks[0].id;
450 + setContext(firstTaskId);
451 + tasksAD.selected = firstTaskId;
452 + localStorage.setItem('lastSelectedTask', firstTaskId);
453 + }
454 + }
455 + } else if (response.tasks && response.tasks.length > 0 && localStorage.getItem('activeTab') === 'tasks') {
456 + // If we're in tasks tab with no selection but have tasks, select the first one
457 + const firstTaskId = response.tasks[0].id;
458 + setContext(firstTaskId);
459 + if (tasksSection) {
460 + const tasksAD = Alpine.$data(tasksSection);
461 + tasksAD.selected = firstTaskId;
462 + localStorage.setItem('lastSelectedTask', firstTaskId);
463 + }
464 + } else if (contexts.length > 0 && localStorage.getItem('activeTab') === 'chats') {
465 + // If we're in chats tab with no selection but have chats, select the first one
466 + const firstChatId = contexts[0].id;
467 + setContext(firstChatId);
468 + chatsAD.selected = firstChatId;
469 + localStorage.setItem('lastSelectedChat', firstChatId);
470 + }
471
472 } catch (error) {
473 console.error('Error:', error);
@@ -443,45 +538,144 @@ window.newChat = async function () {
538 }
539
540 window.killChat = async function (id) {
541 + if (!id) {
542 + console.error("No chat ID provided for deletion");
543 + return;
544 + }
545 +
546 + console.log("Deleting chat with ID:", id);
547 +
548 try {
549 const chatsAD = Alpine.$data(chatsSection);
448 - let found, other
550 + console.log("Current contexts before deletion:", JSON.stringify(chatsAD.contexts.map(c => ({ id: c.id, name: c.name }))));
551 +
552 + // Find an alternate chat to switch to if we're deleting the current one
553 + let alternateChat = null;
554 for (let i = 0; i < chatsAD.contexts.length; i++) {
450 - if (chatsAD.contexts[i].id == id) {
451 - found = true
452 - } else {
453 - other = chatsAD.contexts[i]
555 + if (chatsAD.contexts[i].id !== id) {
556 + alternateChat = chatsAD.contexts[i];
557 + break;
558 }
455 - if (found && other) break
559 }
560
458 - if (context == id && found) {
459 - if (other) setContext(other.id)
460 - else setContext(generateGUID())
561 + // If we're deleting the currently selected chat, switch to another one first
562 + if (context === id) {
563 + if (alternateChat) {
564 + setContext(alternateChat.id);
565 + } else {
566 + // If no other chats, create a new empty context
567 + setContext(generateGUID());
568 + }
569 }
570
463 - if (found) sendJsonData("/chat_remove", { context: id });
571 + // Delete the chat on the server
572 + await sendJsonData("/chat_remove", { context: id });
573
465 - updateAfterScroll()
574 + // Update the UI manually to ensure the correct chat is removed
575 + // Deep clone the contexts array to prevent reference issues
576 + const updatedContexts = chatsAD.contexts.filter(ctx => ctx.id !== id);
577 + console.log("Updated contexts after deletion:", JSON.stringify(updatedContexts.map(c => ({ id: c.id, name: c.name }))));
578 +
579 + // Force UI update by creating a new array
580 + chatsAD.contexts = [...updatedContexts];
581
582 + updateAfterScroll();
583 +
584 + toast("Chat deleted successfully", "success");
585 } catch (e) {
468 - window.toastFetchError("Error creating new chat", e)
586 + console.error("Error deleting chat:", e);
587 + window.toastFetchError("Error deleting chat", e);
588 + }
589 +}
590 +
591 +// Function to ensure proper UI state when switching contexts
592 +function ensureProperTabSelection(contextId) {
593 + // Get current active tab
594 + const activeTab = localStorage.getItem('activeTab') || 'chats';
595 +
596 + // First attempt to determine if this is a task or chat based on the task list
597 + const tasksSection = document.getElementById('tasks-section');
598 + let isTask = false;
599 +
600 + if (tasksSection) {
601 + const tasksAD = Alpine.$data(tasksSection);
602 + if (tasksAD && tasksAD.tasks) {
603 + isTask = tasksAD.tasks.some(task => task.id === contextId);
604 + }
605 + }
606 +
607 + // If we're selecting a task but are in the chats tab, switch to tasks tab
608 + if (isTask && activeTab === 'chats') {
609 + // Store this as the last selected task before switching
610 + localStorage.setItem('lastSelectedTask', contextId);
611 + activateTab('tasks');
612 + return true;
613 + }
614 +
615 + // If we're selecting a chat but are in the tasks tab, switch to chats tab
616 + if (!isTask && activeTab === 'tasks') {
617 + // Store this as the last selected chat before switching
618 + localStorage.setItem('lastSelectedChat', contextId);
619 + activateTab('chats');
620 + return true;
621 }
622 +
623 + return false;
624 }
625
626 window.selectChat = async function (id) {
473 - setContext(id)
474 - updateAfterScroll()
627 + if (id === context) return //already selected
628 +
629 + // Check if we need to switch tabs based on the context type
630 + const tabSwitched = ensureProperTabSelection(id);
631 +
632 + // If we didn't switch tabs, proceed with normal selection
633 + if (!tabSwitched) {
634 + // Switch to the new context - this will clear chat history and reset tracking variables
635 + setContext(id);
636 +
637 + // Update both contexts and tasks lists to reflect the selected item
638 + const chatsAD = Alpine.$data(chatsSection);
639 + const tasksSection = document.getElementById('tasks-section');
640 + if (tasksSection) {
641 + const tasksAD = Alpine.$data(tasksSection);
642 + tasksAD.selected = id;
643 + }
644 + chatsAD.selected = id;
645 +
646 + // Store this selection in the appropriate localStorage key
647 + const activeTab = localStorage.getItem('activeTab') || 'chats';
648 + if (activeTab === 'chats') {
649 + localStorage.setItem('lastSelectedChat', id);
650 + } else if (activeTab === 'tasks') {
651 + localStorage.setItem('lastSelectedTask', id);
652 + }
653 +
654 + // Trigger an immediate poll to fetch content
655 + poll();
656 + }
657 +
658 + updateAfterScroll();
659 }
660
661 export const setContext = function (id) {
478 - if (id == context) return
479 - context = id
480 - lastLogGuid = ""
481 - lastLogVersion = 0
482 - lastSpokenNo = 0
662 + if (id == context) return;
663 + context = id;
664 + // Always reset the log tracking variables when switching contexts
665 + // This ensures we get fresh data from the backend
666 + lastLogGuid = "";
667 + lastLogVersion = 0;
668 + lastSpokenNo = 0;
669 +
670 + // Clear the chat history immediately to avoid showing stale content
671 + chatHistory.innerHTML = "";
672 +
673 + // Update both selected states
674 const chatsAD = Alpine.$data(chatsSection);
484 - chatsAD.selected = id
675 + const tasksAD = Alpine.$data(tasksSection);
676 +
677 + chatsAD.selected = id;
678 + tasksAD.selected = id;
679 }
680
681 export const getContext = function () {
@@ -625,7 +819,7 @@ window.loadChats = async function () {
819 // } else {
820 // toast("Undefined error.", "error")
821 // }
628 - // }
822 + // }
823 else {
824 setContext(response.ctxids[0])
825 toast("Chats loaded.", "success")
@@ -905,7 +1099,7 @@ document.addEventListener('DOMContentLoaded', () => {
1099 dragDropOverlay.addEventListener('drop', (e) => {
1100 dragCounter = 0;
1101 Alpine.$data(dragDropOverlay).isVisible = false;
908 -
1102 +
1103 const inputAD = Alpine.$data(inputSection);
1104 const files = e.dataTransfer.files;
1105 handleFiles(files, inputAD);
@@ -916,9 +1110,9 @@ document.addEventListener('DOMContentLoaded', () => {
1110 function handleFiles(files, inputAD) {
1111 Array.from(files).forEach(file => {
1112 const ext = file.name.split('.').pop().toLowerCase();
919 -
1113 +
1114 const isImage = ['jpg', 'jpeg', 'png', 'bmp'].includes(ext);
921 -
1115 +
1116 if (isImage) {
1117 const reader = new FileReader();
1118 reader.onload = e => {
@@ -941,7 +1135,7 @@ function handleFiles(files, inputAD) {
1135 });
1136 inputAD.hasAttachments = true;
1137 }
944 -
1138 +
1139 });
1140 }
1141
@@ -950,4 +1144,110 @@ window.handleFileUpload = function(event) {
1144 const files = event.target.files;
1145 const inputAD = Alpine.$data(inputSection);
1146 handleFiles(files, inputAD);
953 -}
\ No newline at end of file
1147 +}
1148 +
1149 +// Setup event handlers once the DOM is fully loaded
1150 +document.addEventListener('DOMContentLoaded', function() {
1151 + setupSidebarToggle();
1152 + setupTabs();
1153 + initializeActiveTab();
1154 +});
1155 +
1156 +// Setup tabs functionality
1157 +function setupTabs() {
1158 + const chatsTab = document.getElementById('chats-tab');
1159 + const tasksTab = document.getElementById('tasks-tab');
1160 +
1161 + if (chatsTab && tasksTab) {
1162 + chatsTab.addEventListener('click', function() {
1163 + activateTab('chats');
1164 + });
1165 +
1166 + tasksTab.addEventListener('click', function() {
1167 + activateTab('tasks');
1168 + });
1169 + } else {
1170 + console.error('Tab elements not found');
1171 + setTimeout(setupTabs, 100); // Retry setup
1172 + }
1173 +}
1174 +
1175 +function activateTab(tabName) {
1176 + const chatsTab = document.getElementById('chats-tab');
1177 + const tasksTab = document.getElementById('tasks-tab');
1178 + const chatsSection = document.getElementById('chats-section');
1179 + const tasksSection = document.getElementById('tasks-section');
1180 +
1181 + // Get current context to preserve before switching
1182 + const currentContext = context;
1183 +
1184 + // Store the current selection for the active tab before switching
1185 + const previousTab = localStorage.getItem('activeTab');
1186 + if (previousTab === 'chats') {
1187 + localStorage.setItem('lastSelectedChat', currentContext);
1188 + } else if (previousTab === 'tasks') {
1189 + localStorage.setItem('lastSelectedTask', currentContext);
1190 + }
1191 +
1192 + // Reset all tabs and sections
1193 + chatsTab.classList.remove('active');
1194 + tasksTab.classList.remove('active');
1195 + chatsSection.style.display = 'none';
1196 + tasksSection.style.display = 'none';
1197 +
1198 + // Remember the last active tab in localStorage
1199 + localStorage.setItem('activeTab', tabName);
1200 +
1201 + // Activate selected tab and section
1202 + if (tabName === 'chats') {
1203 + chatsTab.classList.add('active');
1204 + chatsSection.style.display = '';
1205 +
1206 + // Restore previous chat selection
1207 + const lastSelectedChat = localStorage.getItem('lastSelectedChat');
1208 + if (lastSelectedChat && lastSelectedChat !== currentContext) {
1209 + // Only switch if there's a stored selection and it's different from current
1210 + setContext(lastSelectedChat);
1211 + }
1212 + } else if (tabName === 'tasks') {
1213 + tasksTab.classList.add('active');
1214 + tasksSection.style.display = 'flex';
1215 + tasksSection.style.flexDirection = 'column';
1216 +
1217 + // Restore previous task selection
1218 + const lastSelectedTask = localStorage.getItem('lastSelectedTask');
1219 + if (lastSelectedTask && lastSelectedTask !== currentContext) {
1220 + // Only switch if there's a stored selection and it's different from current
1221 + setContext(lastSelectedTask);
1222 + }
1223 + }
1224 +
1225 + // Request a poll update
1226 + poll();
1227 +}
1228 +
1229 +// Add function to initialize active tab and selections from localStorage
1230 +function initializeActiveTab() {
1231 + // Initialize selection storage if not present
1232 + if (!localStorage.getItem('lastSelectedChat')) {
1233 + localStorage.setItem('lastSelectedChat', '');
1234 + }
1235 + if (!localStorage.getItem('lastSelectedTask')) {
1236 + localStorage.setItem('lastSelectedTask', '');
1237 + }
1238 +
1239 + const activeTab = localStorage.getItem('activeTab') || 'chats';
1240 + activateTab(activeTab);
1241 +}
1242 +
1243 +/*
1244 + * A0 Chat UI
1245 + *
1246 + * Tasks tab functionality:
1247 + * - Tasks are displayed in the Tasks tab with the same mechanics as chats
1248 + * - Both lists are sorted by creation time (newest first)
1249 + * - Selection state is preserved across tab switches
1250 + * - The active tab is remembered across sessions
1251 + * - Tasks use the same context system as chats for communication with the backend
1252 + * - Future support for renaming and deletion will be implemented later
1253 + */