pygpt-net 2.6.47__py3-none-any.whl → 2.6.49__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. pygpt_net/CHANGELOG.txt +8 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app_core.py +39 -39
  4. pygpt_net/controller/__init__.py +72 -62
  5. pygpt_net/controller/ctx/common.py +0 -7
  6. pygpt_net/controller/ctx/ctx.py +176 -8
  7. pygpt_net/controller/ctx/extra.py +3 -3
  8. pygpt_net/controller/settings/editor.py +3 -1
  9. pygpt_net/controller/theme/common.py +8 -2
  10. pygpt_net/controller/ui/tabs.py +10 -43
  11. pygpt_net/core/ctx/ctx.py +79 -26
  12. pygpt_net/core/render/web/renderer.py +4 -10
  13. pygpt_net/core/tabs/tabs.py +50 -11
  14. pygpt_net/data/config/config.json +3 -3
  15. pygpt_net/data/config/models.json +3 -3
  16. pygpt_net/data/css/web-blocks.css +256 -270
  17. pygpt_net/data/css/web-chatgpt.css +276 -301
  18. pygpt_net/data/css/web-chatgpt_wide.css +286 -294
  19. pygpt_net/provider/core/config/patch.py +9 -0
  20. pygpt_net/provider/core/ctx/db_sqlite/storage.py +19 -5
  21. pygpt_net/tools/code_interpreter/ui/html.py +176 -31
  22. pygpt_net/tools/code_interpreter/ui/widgets.py +1 -4
  23. pygpt_net/tools/html_canvas/ui/widgets.py +2 -5
  24. pygpt_net/ui/__init__.py +9 -14
  25. pygpt_net/ui/layout/chat/chat.py +2 -2
  26. pygpt_net/ui/layout/ctx/ctx_list.py +71 -1
  27. pygpt_net/ui/widget/lists/base.py +32 -1
  28. pygpt_net/ui/widget/lists/context.py +45 -2
  29. pygpt_net/ui/widget/tabs/body.py +23 -1
  30. pygpt_net/ui/widget/textarea/web.py +85 -45
  31. {pygpt_net-2.6.47.dist-info → pygpt_net-2.6.49.dist-info}/METADATA +10 -2
  32. {pygpt_net-2.6.47.dist-info → pygpt_net-2.6.49.dist-info}/RECORD +35 -35
  33. {pygpt_net-2.6.47.dist-info → pygpt_net-2.6.49.dist-info}/LICENSE +0 -0
  34. {pygpt_net-2.6.47.dist-info → pygpt_net-2.6.49.dist-info}/WHEEL +0 -0
  35. {pygpt_net-2.6.47.dist-info → pygpt_net-2.6.49.dist-info}/entry_points.txt +0 -0
@@ -88,6 +88,15 @@ class Patch:
88
88
  patch_css('web-chatgpt_wide.light.css', True)
89
89
  updated = True
90
90
 
91
+ # < 2.6.48
92
+ if old < parse_version("2.6.48"):
93
+ print("Migrating config from < 2.6.48...")
94
+ # reformat
95
+ patch_css('web-chatgpt.css', True)
96
+ patch_css('web-chatgpt_wide.css', True)
97
+ patch_css('web-blocks.css', True)
98
+ updated = True
99
+
91
100
  # update file
92
101
  migrated = False
93
102
  if updated:
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2024.12.14 22:00:00 #
9
+ # Updated Date: 2025.09.15 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from datetime import datetime
@@ -109,6 +109,12 @@ class Storage:
109
109
  continue
110
110
  mode = filter.get('mode', '=')
111
111
  value = filter.get('value', '')
112
+
113
+ # handle special case for "ungrouped" (group_id IS NULL OR = 0)
114
+ if key == 'group_id' and str(mode).upper() == 'NULL_OR_ZERO':
115
+ where_clauses.append("(m.group_id IS NULL OR m.group_id = 0)")
116
+ continue
117
+
112
118
  key_name = 'm.' + key
113
119
  if isinstance(value, int):
114
120
  where_clauses.append(f"{key_name} {mode} :{key}")
@@ -116,7 +122,7 @@ class Storage:
116
122
  elif isinstance(value, str):
117
123
  where_clauses.append(f"{key_name} {mode} :{key}")
118
124
  bind_params[key] = f"%{value}%"
119
- elif isinstance(value, list):
125
+ elif isinstance(value, list) and len(value) > 0:
120
126
  values = "(" + ",".join([str(x) for x in value]) + ")"
121
127
  where_clauses.append(f"{key_name} {mode} {values}")
122
128
 
@@ -148,15 +154,21 @@ class Storage:
148
154
  :return: dict of CtxMeta
149
155
  """
150
156
  limit_suffix = ""
151
- if limit is not None and limit > 0:
152
- limit_suffix = " LIMIT {}".format(limit)
153
-
154
157
  where_statement, join_statement, bind_params = self.prepare_query(
155
158
  search_string=search_string,
156
159
  filters=filters,
157
160
  search_content=search_content,
158
161
  append_date_ranges=True,
159
162
  )
163
+
164
+ # Build LIMIT/OFFSET only when limit > 0; LIMIT 0 would mean "no rows"
165
+ if limit is not None and int(limit) > 0:
166
+ limit_suffix = " LIMIT :limit"
167
+ bind_params['limit'] = int(limit)
168
+ if offset is not None and int(offset) > 0:
169
+ limit_suffix += " OFFSET :offset"
170
+ bind_params['offset'] = int(offset)
171
+
160
172
  stmt_text = f"""
161
173
  SELECT
162
174
  m.*,
@@ -168,6 +180,8 @@ class Storage:
168
180
  {join_statement}
169
181
  WHERE
170
182
  {where_statement}
183
+ GROUP BY
184
+ m.id
171
185
  ORDER BY
172
186
  m.updated_ts DESC {limit_suffix}
173
187
  """
@@ -6,16 +6,16 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.09.16 02:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import json
13
13
  import re
14
14
  from typing import Optional
15
15
 
16
- from PySide6.QtCore import Qt, QObject, Signal, Slot, QEvent, QTimer
16
+ from PySide6.QtCore import Qt, QObject, Signal, Slot, QEvent, QTimer, QUrl, QCoreApplication, QEventLoop
17
17
  from PySide6.QtWebChannel import QWebChannel
18
- from PySide6.QtWebEngineCore import QWebEngineSettings, QWebEnginePage
18
+ from PySide6.QtWebEngineCore import QWebEngineSettings, QWebEnginePage, QWebEngineProfile
19
19
  from PySide6.QtWebEngineWidgets import QWebEngineView
20
20
  from PySide6.QtGui import QAction, QIcon, QKeySequence
21
21
  from PySide6.QtWidgets import QMenu
@@ -25,6 +25,7 @@ from pygpt_net.core.text.web_finder import WebFinder
25
25
  from pygpt_net.tools.code_interpreter.body import Body
26
26
  from pygpt_net.utils import trans
27
27
 
28
+
28
29
  class CodeBlock:
29
30
  def __init__(
30
31
  self,
@@ -109,32 +110,119 @@ class HtmlOutput(QWebEngineView):
109
110
  self.is_dialog = False
110
111
  self.nodes = [] # code blocks
111
112
 
113
+ # OpenGL widgets
114
+ self._glwidget = None
115
+ self._glwidget_filter_installed = False
116
+ self._unloaded = False # flag to check if unloaded
117
+ self._destroyed = False
118
+
119
+ # self._profile = self._make_profile(self)
120
+ self.setPage(CustomWebEnginePage(self.window, self, profile=None))
121
+
122
+ def _make_profile(self, parent=None) -> QWebEngineProfile:
123
+ """Make profile"""
124
+ profile = QWebEngineProfile()
125
+ profile.setPersistentCookiesPolicy(QWebEngineProfile.NoPersistentCookies)
126
+ profile.setSpellCheckEnabled(False)
127
+ return profile
128
+
129
+ def _detach_gl_event_filter(self):
130
+ """Detach OpenGL widget event filter if installed"""
131
+ if self._glwidget and self._glwidget_filter_installed:
132
+ try:
133
+ self._glwidget.removeEventFilter(self)
134
+ except Exception as e:
135
+ self._on_delete_failed(e)
136
+ self._glwidget = None
137
+ self._glwidget_filter_installed = False
138
+
139
+ def _on_delete_failed(self, e):
140
+ """
141
+ Handle delete failure
142
+
143
+ :param e: Exception instance
144
+ """
145
+ pass
146
+ # self.window.core.debug.log(e)
147
+
148
+ def unload(self):
149
+ """Unload the current page and free resources"""
150
+ try:
151
+ self.hide()
152
+ p = self.page()
153
+ p.triggerAction(QWebEnginePage.Stop)
154
+ p.setUrl(QUrl("about:blank"))
155
+ p.history().clear()
156
+ p.setLifecycleState(QWebEnginePage.LifecycleState.Discarded)
157
+ except Exception as e:
158
+ self._on_delete_failed(e)
159
+ finally:
160
+ self._unloaded = True
161
+
112
162
  def on_delete(self):
113
163
  """Clean up on delete"""
164
+ if self._destroyed:
165
+ return
166
+ if not self._unloaded:
167
+ self.unload()
168
+
169
+ self.hide()
170
+ self._detach_gl_event_filter()
171
+
114
172
  if self.finder:
115
- self.finder.disconnect() # disconnect finder
116
- self.finder = None # delete finder
173
+ try:
174
+ self.finder.disconnect()
175
+ except Exception as e:
176
+ self._on_delete_failed(e)
177
+ finally:
178
+ self.finder = None
117
179
 
118
- self.tab = None # clear tab reference
180
+ self.tab = None
181
+ self.meta = None
119
182
 
120
- # delete page
121
183
  page = self.page()
122
- if page:
123
- if hasattr(page, 'bridge'):
124
- page.bridge.deleteLater()
125
- if hasattr(page, 'channel'):
126
- page.channel.deleteLater()
127
- if hasattr(page, 'signals') and page.signals:
128
- page.signals.deleteLater()
129
- page.deleteLater() # delete page
184
+ prof = page.profile()
130
185
 
131
186
  # disconnect signals
132
- self.loadFinished.disconnect(self.on_page_loaded)
133
- self.customContextMenuRequested.disconnect(self.on_context_menu)
134
- self.signals.save_as.disconnect(self.window.controller.chat.render.handle_save_as)
135
- self.signals.audio_read.disconnect(self.window.controller.chat.render.handle_audio_read)
187
+ try:
188
+ self.loadFinished.disconnect()
189
+ self.customContextMenuRequested.disconnect()
190
+ self.signals.save_as.disconnect()
191
+ self.signals.audio_read.disconnect()
192
+ except Exception as e:
193
+ self._on_delete_failed(e)
194
+
195
+ try:
196
+ if hasattr(page, "setWebChannel"):
197
+ page.setWebChannel(None)
198
+ except Exception as e:
199
+ self._on_delete_failed(e)
200
+
201
+ try:
202
+ page.cleanup()
203
+ except Exception as e:
204
+ self._on_delete_failed(e)
205
+
206
+ """
207
+ if prof is not None:
208
+ try:
209
+ prof.deleteLater()
210
+ except Exception as e:
211
+ self._on_delete_failed(e)
212
+ """
213
+
214
+ try:
215
+ self.deleteLater()
216
+ except Exception as e:
217
+ self._on_delete_failed(e)
218
+
219
+ try:
220
+ QCoreApplication.sendPostedEvents(None, QEvent.DeferredDelete)
221
+ QCoreApplication.processEvents(QEventLoop.AllEvents, 50)
222
+ except Exception as e:
223
+ self._on_delete_failed(e)
136
224
 
137
- self.deleteLater() # delete widget
225
+ self._destroyed = True
138
226
 
139
227
  def init(self, force: bool = False):
140
228
  """
@@ -164,7 +252,7 @@ class HtmlOutput(QWebEngineView):
164
252
 
165
253
  def reload(self):
166
254
  """Reload HTML output"""
167
- #return
255
+ # return
168
256
  self.reload_css()
169
257
 
170
258
  def get_nodes(self) -> list:
@@ -203,7 +291,7 @@ class HtmlOutput(QWebEngineView):
203
291
 
204
292
  :param text: text
205
293
  """
206
- self.plain+= str(text)
294
+ self.plain += str(text)
207
295
 
208
296
  def set_plaintext(self, text: str):
209
297
  """
@@ -267,7 +355,7 @@ class HtmlOutput(QWebEngineView):
267
355
  if node.content != "":
268
356
  self.nodes.append(node)
269
357
  if self.loaded:
270
- self.insert_output(node)
358
+ self.insert_output(node)
271
359
  self.update_current_content()
272
360
  QTimer.singleShot(100, self.scroll_to_bottom) # wait for rendering to complete
273
361
 
@@ -508,7 +596,7 @@ class HtmlOutput(QWebEngineView):
508
596
  menu.addSeparator()
509
597
  action = QAction(QIcon(":/icons/search.svg"), trans('text.context_menu.find'), self)
510
598
  action.triggered.connect(self.find_open)
511
- #action.setShortcut(QKeySequence("Ctrl+F"))
599
+ # action.setShortcut(QKeySequence("Ctrl+F"))
512
600
  menu.addAction(action)
513
601
 
514
602
  menu.exec_(self.mapToGlobal(position))
@@ -615,14 +703,20 @@ class HtmlOutput(QWebEngineView):
615
703
  if (event.type() == QEvent.ChildAdded and
616
704
  source is self and
617
705
  event.child().isWidgetType()):
706
+ self._detach_gl_event_filter()
618
707
  self._glwidget = event.child()
619
- self._glwidget.installEventFilter(self)
708
+ try:
709
+ self._glwidget.installEventFilter(self)
710
+ self._glwidget_filter_installed = True
711
+ except Exception:
712
+ self._glwidget = None
713
+ self._glwidget_filter_installed = False
620
714
  elif event.type() == event.Type.MouseButtonPress:
621
715
  if self.tab:
622
716
  col_idx = self.tab.column_idx
623
717
  self.window.controller.ui.tabs.on_column_focus(col_idx)
624
718
  elif event.type() == event.Type.FocusIn:
625
- if self.tab is not None:
719
+ if self.tab:
626
720
  col_idx = self.tab.column_idx
627
721
  self.window.controller.ui.tabs.on_column_focus(col_idx)
628
722
  elif event.type() == QEvent.KeyPress:
@@ -633,10 +727,17 @@ class HtmlOutput(QWebEngineView):
633
727
 
634
728
  class CustomWebEnginePage(QWebEnginePage):
635
729
  """Custom WebEnginePage to handle web events"""
636
- def __init__(self, window, parent):
637
- super(CustomWebEnginePage, self).__init__(window)
730
+
731
+ def __init__(self, window, view, profile: QWebEngineProfile = None):
732
+
733
+ # use the profile if provided, otherwise the default
734
+ if profile is not None:
735
+ super(CustomWebEnginePage, self).__init__(profile, view)
736
+ else:
737
+ super(CustomWebEnginePage, self).__init__(view)
738
+
638
739
  self.window = window
639
- self.parent = parent
740
+ self.parent = view
640
741
  self.signals = WebEnginePageSignals()
641
742
  self.findTextFinished.connect(self.on_find_finished)
642
743
  self.zoomFactorChanged.connect(self.on_view_changed)
@@ -687,7 +788,7 @@ class CustomWebEnginePage(QWebEnginePage):
687
788
  """On selection changed"""
688
789
  pass
689
790
 
690
- def acceptNavigationRequest(self, url, _type, isMainFrame):
791
+ def acceptNavigationRequest(self, url, _type, isMainFrame):
691
792
  """
692
793
  On navigation (link click) event
693
794
 
@@ -698,7 +799,7 @@ class CustomWebEnginePage(QWebEnginePage):
698
799
  if _type == QWebEnginePage.NavigationTypeLinkClicked:
699
800
  self.window.core.filesystem.url.handle(url)
700
801
  return False
701
- return super().acceptNavigationRequest(url, _type, isMainFrame)
802
+ return super().acceptNavigationRequest(url, _type, isMainFrame)
702
803
 
703
804
  def javaScriptConsoleMessage(self, level, message, line_number, source_id):
704
805
  """
@@ -711,9 +812,40 @@ class CustomWebEnginePage(QWebEnginePage):
711
812
  """
712
813
  self.signals.js_message.emit(line_number, message, source_id) # handled in debug controller
713
814
 
815
+ def cleanup(self):
816
+ """Cleanup method to release resources"""
817
+ try:
818
+ self.findTextFinished.disconnect()
819
+ self.zoomFactorChanged.disconnect()
820
+ self.selectionChanged.disconnect()
821
+ except Exception:
822
+ pass
823
+
824
+ if self.bridge:
825
+ try:
826
+ self.bridge.cleanup()
827
+ except Exception:
828
+ pass
829
+ self.bridge = None
830
+
831
+ if self.channel:
832
+ try:
833
+ self.channel.unregisterObject("bridge")
834
+ except Exception:
835
+ pass
836
+ self.channel = None
837
+
838
+ if self.signals:
839
+ try:
840
+ self.signals.deleteLater()
841
+ except Exception:
842
+ pass
843
+ self.signals = None
844
+
714
845
 
715
846
  class Bridge(QObject):
716
847
  """Bridge between Python and JavaScript"""
848
+
717
849
  def __init__(self, window):
718
850
  super(Bridge, self).__init__(window)
719
851
  self.window = window
@@ -754,10 +886,23 @@ class Bridge(QObject):
754
886
  """
755
887
  self.window.controller.chat.render.scroll = pos
756
888
 
889
+ def cleanup(self):
890
+ """Cleanup method to release resources"""
891
+ if self.window:
892
+ try:
893
+ self.window = None
894
+ except Exception:
895
+ pass
896
+
897
+ # delete the bridge object
898
+ self.deleteLater()
899
+
900
+
757
901
  class WebEngineSignals(QObject):
758
902
  save_as = Signal(str, str)
759
903
  audio_read = Signal(str)
760
904
 
905
+
761
906
  class WebEnginePageSignals(QObject):
762
907
  js_message = Signal(int, str, str) # on Javascript message
763
908
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.09.16 02:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6 import QtCore
@@ -100,9 +100,6 @@ class ToolWidget:
100
100
  :return: QVBoxLayout
101
101
  """
102
102
  self.output = HtmlOutput(self.window, self.tool)
103
- self.output.setPage(
104
- CustomWebEnginePage(self.window, self.output)
105
- )
106
103
  self.output.signals.save_as.connect(self.window.controller.chat.render.handle_save_as)
107
104
  self.output.signals.audio_read.connect(self.window.controller.chat.render.handle_audio_read)
108
105
 
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.09.16 02:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import Qt, Slot, QUrl, QObject, Signal
@@ -14,7 +14,7 @@ from PySide6.QtWidgets import QVBoxLayout, QCheckBox, QHBoxLayout
14
14
 
15
15
  from pygpt_net.ui.widget.element.labels import HelpLabel
16
16
  from pygpt_net.ui.widget.textarea.editor import BaseCodeEditor
17
- from pygpt_net.ui.widget.textarea.html import HtmlOutput, CustomWebEnginePage
17
+ from pygpt_net.ui.widget.textarea.html import HtmlOutput
18
18
  from pygpt_net.utils import trans
19
19
 
20
20
 
@@ -61,9 +61,6 @@ class ToolWidget:
61
61
  :return: QVBoxLayout
62
62
  """
63
63
  self.output = CanvasOutput(self.window)
64
- self.output.setPage(
65
- CustomWebEnginePage(self.window, self.output)
66
- )
67
64
  self.edit = CanvasEdit(self.window)
68
65
  self.edit.setVisible(False)
69
66
  self.edit.textChanged.connect(
pygpt_net/ui/__init__.py CHANGED
@@ -16,13 +16,13 @@ from PySide6.QtCore import Qt, QTimer
16
16
  from PySide6.QtGui import QFontDatabase, QIcon
17
17
  from PySide6.QtWidgets import QSplitter, QMessageBox
18
18
 
19
- from pygpt_net.ui.base.context_menu import ContextMenu
20
- from pygpt_net.ui.dialogs import Dialogs
21
- from pygpt_net.ui.layout.chat import ChatMain
22
- from pygpt_net.ui.layout.ctx import CtxMain
23
- from pygpt_net.ui.layout.toolbox import ToolboxMain
24
- from pygpt_net.ui.menu import Menu
25
- from pygpt_net.ui.tray import Tray
19
+ from .base.context_menu import ContextMenu
20
+ from .dialogs import Dialogs
21
+ from .layout.chat import ChatMain
22
+ from .layout.ctx import CtxMain
23
+ from .layout.toolbox import ToolboxMain
24
+ from .menu import Menu
25
+ from .tray import Tray
26
26
 
27
27
 
28
28
  class UI:
@@ -139,12 +139,7 @@ class UI:
139
139
  suffix = self.window.core.platforms.get_env_suffix()
140
140
  profile_name = self.window.core.config.profile.get_current_name()
141
141
  self.window.setWindowTitle(
142
- 'PyGPT - Desktop AI Assistant {} | build {}{} ({})'.format(
143
- self.window.meta['version'],
144
- self.window.meta['build'].replace('.', '-'),
145
- suffix,
146
- profile_name,
147
- )
142
+ f"PyGPT - Desktop AI Assistant {self.window.meta['version']} | build {self.window.meta['build'].replace('.', '-')}{suffix} ({profile_name})"
148
143
  )
149
144
 
150
145
  def post_setup(self):
@@ -164,7 +159,7 @@ class UI:
164
159
  return
165
160
  msg = str(text)
166
161
  msg = msg.replace("\n", " ")
167
- status = msg[:self.STATUS_MAX_CHARS] + '...' if len(msg) > self.STATUS_MAX_CHARS else msg # truncate
162
+ status = f"{msg[:self.STATUS_MAX_CHARS]}..." if len(msg) > self.STATUS_MAX_CHARS else msg # truncate
168
163
  self.nodes['status'].setText(status)
169
164
 
170
165
  def get_status(self):
@@ -12,8 +12,8 @@
12
12
  from PySide6.QtCore import Qt, Slot
13
13
  from PySide6.QtWidgets import QSplitter
14
14
 
15
- from pygpt_net.ui.layout.chat.input import Input
16
- from pygpt_net.ui.layout.chat.output import Output
15
+ from .input import Input
16
+ from .output import Output
17
17
 
18
18
 
19
19
  class ChatMain:
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.12 23:00:00 #
9
+ # Updated Date: 2025.09.15 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6 import QtCore
@@ -113,9 +113,79 @@ class CtxList:
113
113
  self.update_items_pinned(id, data)
114
114
  self.update_items(id, data)
115
115
  self.update_groups(id, data, expand=expand)
116
+
117
+ # APPLY PENDING SCROLL BEFORE RE-ENABLING UPDATES (prevents top flicker)
118
+ try:
119
+ node.apply_pending_scroll()
120
+ node.clear_pending_scroll()
121
+ except Exception:
122
+ pass
116
123
  finally:
117
124
  node.setUpdatesEnabled(True)
118
125
 
126
+ def _find_first_group_row(self, model) -> int:
127
+ """Find the row index of the first GroupItem; return -1 if none."""
128
+ for r in range(model.rowCount()):
129
+ it = model.item(r)
130
+ if isinstance(it, GroupItem):
131
+ return r
132
+ return -1
133
+
134
+ def append_unpaginated(self, id: str, data: dict, add_ids: list[int]):
135
+ """
136
+ Append more ungrouped and not pinned items without rebuilding the model.
137
+ Keeps scroll position perfectly stable.
138
+ """
139
+ if not add_ids:
140
+ return
141
+ node = self.window.ui.nodes[id]
142
+ model = self.window.ui.models[id]
143
+
144
+ folders_top = bool(self.window.core.config.get("ctx.records.folders.top"))
145
+ # decide insertion point: at the end, or just before the first group row
146
+ insert_pos = model.rowCount()
147
+ if not folders_top:
148
+ grp_idx = self._find_first_group_row(model)
149
+ insert_pos = grp_idx if grp_idx >= 0 else model.rowCount()
150
+
151
+ # find last dt of existing ungrouped area before insertion point (for date sections)
152
+ last_dt_str = None
153
+ for r in range(insert_pos - 1, -1, -1):
154
+ it = model.item(r)
155
+ if isinstance(it, Item):
156
+ data_role = it.data(QtCore.Qt.ItemDataRole.UserRole) or {}
157
+ if not data_role.get("in_group", False) and not data_role.get("is_important", False):
158
+ last_dt_str = getattr(it, "dt", None)
159
+ break
160
+ elif isinstance(it, GroupItem):
161
+ break # hit groups boundary going upwards
162
+ else:
163
+ # SectionItem or others – skip
164
+ continue
165
+
166
+ node.setUpdatesEnabled(False)
167
+ try:
168
+ # append strictly in the order provided by add_ids (older first)
169
+ for mid in add_ids:
170
+ meta = data.get(mid)
171
+ if meta is None:
172
+ continue
173
+ item = self.build_item(mid, meta, is_group=False)
174
+
175
+ # Optional date sections (same logic as in update_items)
176
+ if self._group_separators and (not item.isPinned or self._pinned_separators):
177
+ if last_dt_str is None or last_dt_str != item.dt:
178
+ section = self.build_date_section(item.dt, group=False)
179
+ if section:
180
+ model.insertRow(insert_pos, section)
181
+ insert_pos += 1
182
+ last_dt_str = item.dt
183
+
184
+ model.insertRow(insert_pos, item)
185
+ insert_pos += 1
186
+ finally:
187
+ node.setUpdatesEnabled(True)
188
+
119
189
  def update_items(self, id, data):
120
190
  """
121
191
  Update items
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.07.19 17:00:00 #
9
+ # Updated Date: 2025.09.15 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtCore import QItemSelectionModel
@@ -36,6 +36,10 @@ class BaseList(QTreeView):
36
36
  self.v_scroll_value = 0
37
37
  self.h_scroll_value = 0
38
38
 
39
+ # pending scroll values applied while updates are disabled (to avoid top flicker)
40
+ self._pending_v_scroll_value = None
41
+ self._pending_h_scroll_value = None
42
+
39
43
  def click(self, val):
40
44
  self.window.controller.mode.select(self.id)
41
45
  self.selection = self.selectionModel().selection()
@@ -103,3 +107,30 @@ class BaseList(QTreeView):
103
107
  """Restore scroll position"""
104
108
  self.verticalScrollBar().setValue(self.v_scroll_value)
105
109
  self.horizontalScrollBar().setValue(self.h_scroll_value)
110
+
111
+ def set_pending_v_scroll(self, value: int):
112
+ """
113
+ Set vertical scroll value to apply while updates are disabled.
114
+ This prevents a visible jump to the top during model rebuild.
115
+ """
116
+ self._pending_v_scroll_value = int(value)
117
+
118
+ def set_pending_h_scroll(self, value: int):
119
+ """Optional: set horizontal pending value."""
120
+ self._pending_h_scroll_value = int(value)
121
+
122
+ def clear_pending_scroll(self):
123
+ """Clear pending scroll values."""
124
+ self._pending_v_scroll_value = None
125
+ self._pending_h_scroll_value = None
126
+
127
+ def apply_pending_scroll(self):
128
+ """
129
+ Apply pending scroll values immediately.
130
+ IMPORTANT: Call this before re-enabling updates to avoid repaint at top.
131
+ """
132
+ if self._pending_v_scroll_value is not None:
133
+ self.verticalScrollBar().setValue(self._pending_v_scroll_value)
134
+ if self._pending_h_scroll_value is not None:
135
+ self.horizontalScrollBar().setValue(self._pending_h_scroll_value)
136
+ # do not clear here; let caller decide when to clear