Chae commited on
Commit
fec7628
·
1 Parent(s): 6766c9d

feat: add chat history, edit panel

Browse files
Files changed (6) hide show
  1. .DS_Store +0 -0
  2. Dockerfile +1 -1
  3. chat.py +3 -3
  4. edit_history.py +110 -0
  5. inplace_chat.py +103 -50
  6. streamlit_app.py +169 -51
.DS_Store CHANGED
Binary files a/.DS_Store and b/.DS_Store differ
 
Dockerfile CHANGED
@@ -16,4 +16,4 @@ EXPOSE 8501
16
 
17
  HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
18
 
19
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
16
 
17
  HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
18
 
19
+ ENTRYPOINT ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
chat.py CHANGED
@@ -56,13 +56,13 @@ if prompt := st.chat_input("Send a message"):
56
  )
57
 
58
  ######## debug #########
59
- print("=== RAW RESPONSE ===")
60
- print(json.dumps(response.__dict__, indent=2, default=str))
61
 
62
  # show the response
63
  reply = response.choices[0].message["content"]
64
  st.markdown(reply)
65
- print(reply)
66
 
67
  # reasoning = getattr(response.choices[0], "reasoning", None)
68
  # if reasoning:
 
56
  )
57
 
58
  ######## debug #########
59
+ # print("=== RAW RESPONSE ===")
60
+ # print(json.dumps(response.__dict__, indent=2, default=str))
61
 
62
  # show the response
63
  reply = response.choices[0].message["content"]
64
  st.markdown(reply)
65
+ # print(reply)
66
 
67
  # reasoning = getattr(response.choices[0], "reasoning", None)
68
  # if reasoning:
edit_history.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # edit_history.py
2
+ import difflib
3
+ from datetime import datetime
4
+ import streamlit as st
5
+
6
+
7
+ def init_edit_history():
8
+ """Ensure edit history list exists in session_state."""
9
+ if "edit_history" not in st.session_state:
10
+ st.session_state.edit_history = []
11
+
12
+
13
+ def add_edit(old_text: str, new_text: str, source: str | None = None):
14
+ """
15
+ Append an edit event to history.
16
+
17
+ - old_text: text before edit
18
+ - new_text: text after edit
19
+ - source: where this edit came from (e.g., "In-place Chat", "System")
20
+ """
21
+ init_edit_history()
22
+ st.session_state.edit_history.append(
23
+ {
24
+ "old": old_text,
25
+ "new": new_text,
26
+ "source": source,
27
+ "ts": datetime.now().strftime("%H:%M:%S"),
28
+ }
29
+ )
30
+
31
+
32
+ def _render_diff_html(old: str, new: str) -> str:
33
+ """
34
+ Return HTML for a git-style diff:
35
+ - removed tokens: red, strikethrough
36
+ - added tokens: green
37
+ - unchanged: normal
38
+ """
39
+ # word-level diff
40
+ diff = difflib.ndiff(old.split(), new.split())
41
+ parts: list[str] = []
42
+
43
+ for token in diff:
44
+ code = token[:2] # "+ ", "- ", or " "
45
+ word = token[2:]
46
+
47
+ if code == "+ ":
48
+ # Added text in green
49
+ parts.append(
50
+ f"<span style='color:#22c55e;'>{word}</span>"
51
+ )
52
+ elif code == "- ":
53
+ # Removed text in red with strikethrough
54
+ parts.append(
55
+ f"<span style='color:#ef4444;text-decoration:line-through;'>{word}</span>"
56
+ )
57
+ else:
58
+ parts.append(word)
59
+
60
+ return " ".join(parts)
61
+
62
+
63
+ def render_edit_history_sidebar(max_items: int = 20):
64
+ """
65
+ Render the edit history inside the sidebar.
66
+
67
+ Call this inside a sidebar container.
68
+ """
69
+ init_edit_history()
70
+
71
+ with st.expander("Edit history", expanded=True):
72
+ history = st.session_state.edit_history
73
+
74
+ if not history:
75
+ st.caption("No edits yet. Edits will appear here.")
76
+ return
77
+
78
+ # Show most recent first
79
+ for idx, item in enumerate(reversed(history[-max_items:]), start=1):
80
+ header_html = "<div style='margin-bottom:0.15rem;'>"
81
+
82
+ # Title + meta info
83
+ meta_bits = []
84
+ if "ts" in item:
85
+ meta_bits.append(item["ts"])
86
+ if item.get("source"):
87
+ meta_bits.append(item["source"])
88
+
89
+ meta_html = " · ".join(meta_bits)
90
+
91
+ header_html += f"<strong>Edit {len(history) - idx + 1}</strong>"
92
+ if meta_html:
93
+ header_html += (
94
+ f" <span style='font-size:0.75rem;color:#9ca3af;'>"
95
+ f"{meta_html}</span>"
96
+ )
97
+ header_html += "</div>"
98
+
99
+ st.markdown(header_html, unsafe_allow_html=True)
100
+
101
+ diff_html = _render_diff_html(item["old"], item["new"])
102
+ st.markdown(
103
+ f"<div style='font-size:0.85rem;'>{diff_html}</div>",
104
+ unsafe_allow_html=True,
105
+ )
106
+
107
+ st.markdown(
108
+ "<hr style='margin:0.35rem 0;border:none;border-top:1px solid #e5e7eb;'/>",
109
+ unsafe_allow_html=True,
110
+ )
inplace_chat.py CHANGED
@@ -16,18 +16,42 @@ hf_token = os.getenv("HF_TOKEN")
16
  client = InferenceClient(model="openai/gpt-oss-20b", token=hf_token)
17
 
18
  ####### initialize session states ######
19
- if "chat_history" not in st.session_state:
20
- st.session_state.chat_history = [] # [{"role": "user", ...}, {"role": "assistant", ...}]
21
- if "original_user_prompt" not in st.session_state:
22
- st.session_state.original_user_prompt = ""
23
- if "editable" not in st.session_state:
24
- st.session_state.editable = False
25
- if "prev_text" not in st.session_state:
26
- st.session_state.prev_text = ""
27
- if "edited_text" not in st.session_state:
28
- st.session_state.edited_text = ""
29
- if "prev_msgs" not in st.session_state:
30
- st.session_state.prev_msgs = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  # === Utility: compact "removed vs added" summary for the inplace_prefix ===
33
  ### later separate this into parse_diff.py
@@ -51,19 +75,41 @@ def summarize_edit(old: str, new: str) -> tuple[str, str]:
51
  edited_text = " / ".join(added_chunks) if added_chunks else "(none)"
52
  return removed_text, edited_text
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  # === Render current conversation ===
55
- editable=st.session_state.editable
56
- print(editable)
57
- for i, msg in enumerate(st.session_state.chat_history):
58
- print(i, msg)
59
- if st.session_state.editable and msg["role"] == "assistant":
60
  continue
61
  with st.chat_message(msg["role"]):
62
  st.markdown(msg["content"])
63
 
64
  if prompt := st.chat_input("Send a message"):
65
- st.session_state.original_user_prompt = prompt
66
- st.session_state.chat_history.append({"role": "user", "content": prompt})
 
 
 
 
 
67
  with st.chat_message("user"):
68
  st.markdown(prompt)
69
 
@@ -82,39 +128,37 @@ if prompt := st.chat_input("Send a message"):
82
  top_p=0.95,
83
  stream=False,
84
  )
85
-
86
  reply = response.choices[0].message["content"]
87
  st.markdown(reply)
88
- st.session_state.chat_history.append(
89
  {"role": "assistant", "content": reply}
90
  )
91
- st.session_state.prev_text = reply
92
- # save previous messages for reference
93
- st.session_state.prev_msgs.append(reply)
94
-
95
  except Exception as e:
96
  st.error(f"Error: {e}")
97
 
98
  # Edit features
99
- if st.session_state.chat_history and st.session_state.chat_history[-1]["role"] == "assistant":
100
  # button
101
  with st.chat_message("assistant"):
102
  col_spacer, col_edit = st.columns([0.93, 0.07])
103
  with col_edit:
104
- if not st.session_state.editable:
105
  if st.button("✏️", key="edit_btn", help="Edit response"):
106
- st.session_state.editable = True
107
  st.rerun()
108
-
109
  ######## Edit mode #########
110
- if st.session_state.editable:
111
  st.markdown('<div class="editable-on">', unsafe_allow_html=True)
112
  with st.chat_message("assistant"):
113
  st.caption("Editing the last response…")
114
  with st.form("edit_form", clear_on_submit=False):
115
- st.session_state.edited_text = st.text_area(
116
  " ",
117
- value=st.session_state.prev_text,
118
  height=500,
119
  label_visibility="collapsed",
120
  key="edit_textarea",
@@ -127,15 +171,27 @@ if st.session_state.chat_history and st.session_state.chat_history[-1]["role"] =
127
  st.markdown("</div>", unsafe_allow_html=True)
128
 
129
  # === Handle edit submission (backend: in-place continuation) ===
130
- if st.session_state.editable and finished_edit:
131
- if st.session_state.chat_history and st.session_state.chat_history[-1]["role"] == "assistant":
132
- st.session_state.chat_history.pop()
133
- removed_text, added_text = summarize_edit(st.session_state.prev_text, st.session_state.edited_text)
134
-
 
 
 
 
 
 
 
 
 
 
 
 
135
  system_prompt = "The user edited your previous answer. Only continue from the assistant message above. Do NOT rewrite or repeat it."
136
-
137
  # Exit edit mode before generation
138
- st.session_state.editable = False
139
 
140
  with st.chat_message("assistant"):
141
  with st.spinner("Continuing from your edit…"):
@@ -143,29 +199,26 @@ if st.session_state.chat_history and st.session_state.chat_history[-1]["role"] =
143
  resp = client.chat_completion(
144
  messages=[
145
  {"role": "system", "content": system_prompt},
146
- {"role": "assistant", "content": st.session_state.edited_text},
147
  {"role": "user", "content": "Continue exactly from the assistant message above. Do not restate any of it; just append."},
148
  ],
149
-
150
  max_tokens=60000,
151
  temperature=0.7,
152
  top_p=0.95,
153
  )
154
 
155
  generated = resp.choices[0].message["content"]
156
- combined = st.session_state.edited_text + '\n\n'+generated
157
- st.session_state.prev_text = combined
158
- # print("generated text after feedback")
159
- # print(generated)
160
- st.session_state.chat_history.append(
161
  {"role": "assistant", "content": combined}
162
  )
163
- print(st.session_state.chat_history)
164
  st.rerun()
165
 
166
  except Exception as e:
167
  st.error(f"Error while continuing from edit: {e}")
168
- st.session_state.chat_history.append(
169
- {"role": "assistant", "content": st.session_state.prev_text}
170
  )
171
- st.session_state.editable = False
 
16
  client = InferenceClient(model="openai/gpt-oss-20b", token=hf_token)
17
 
18
  ####### initialize session states ######
19
+ # These are managed globally in streamlit_app.py, but we ensure they exist
20
+ if "current_chat_id" not in st.session_state:
21
+ st.session_state.current_chat_id = None
22
+ if "conversations" not in st.session_state:
23
+ st.session_state.conversations = {}
24
+
25
+ # Get or create current conversation
26
+ chat_id = st.session_state.current_chat_id
27
+ if chat_id is None or chat_id not in st.session_state.conversations:
28
+ # This shouldn't happen if streamlit_app.py is the entry point, but just in case
29
+ import uuid
30
+ chat_id = str(uuid.uuid4())
31
+ st.session_state.conversations[chat_id] = {
32
+ "title": "New chat",
33
+ "messages": [],
34
+ "editable": False,
35
+ "prev_text": "",
36
+ "edited_text": "",
37
+ "original_user_prompt": "",
38
+ "edit_history": []
39
+ }
40
+ st.session_state.current_chat_id = chat_id
41
+
42
+ conv = st.session_state.conversations[chat_id]
43
+
44
+ # Ensure all required keys exist in the current conversation
45
+ conv.setdefault("messages", [])
46
+ conv.setdefault("editable", False)
47
+ conv.setdefault("prev_text", "")
48
+ conv.setdefault("edited_text", "")
49
+ conv.setdefault("original_user_prompt", "")
50
+ conv.setdefault("title", "New chat")
51
+ conv.setdefault("edit_history", []) # List of {removed, added, timestamp}
52
+
53
+ # Shorthand references
54
+ chat_history = conv["messages"]
55
 
56
  # === Utility: compact "removed vs added" summary for the inplace_prefix ===
57
  ### later separate this into parse_diff.py
 
75
  edited_text = " / ".join(added_chunks) if added_chunks else "(none)"
76
  return removed_text, edited_text
77
 
78
+ def get_detailed_diff(old: str, new: str) -> list[dict]:
79
+ """
80
+ Returns a list of diff chunks with tags: 'equal', 'delete', 'insert', 'replace'
81
+ Each chunk has: {'tag': str, 'text': str}
82
+ """
83
+ sm = difflib.SequenceMatcher(a=old, b=new)
84
+ diff_chunks = []
85
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
86
+ if tag == 'equal':
87
+ diff_chunks.append({'tag': 'equal', 'text': old[i1:i2]})
88
+ elif tag == 'delete':
89
+ diff_chunks.append({'tag': 'delete', 'text': old[i1:i2]})
90
+ elif tag == 'insert':
91
+ diff_chunks.append({'tag': 'insert', 'text': new[j1:j2]})
92
+ elif tag == 'replace':
93
+ diff_chunks.append({'tag': 'delete', 'text': old[i1:i2]})
94
+ diff_chunks.append({'tag': 'insert', 'text': new[j1:j2]})
95
+ return diff_chunks
96
+
97
  # === Render current conversation ===
98
+ editable = conv["editable"]
99
+ for i, msg in enumerate(chat_history):
100
+ if editable and i == len(chat_history) - 1 and msg["role"] == "assistant":
 
 
101
  continue
102
  with st.chat_message(msg["role"]):
103
  st.markdown(msg["content"])
104
 
105
  if prompt := st.chat_input("Send a message"):
106
+ conv["original_user_prompt"] = prompt
107
+ chat_history.append({"role": "user", "content": prompt})
108
+
109
+ # Update conversation title with first user message
110
+ if conv["title"] == "New chat" and len(chat_history) == 1:
111
+ conv["title"] = prompt[:50] # Use first 50 chars of first message
112
+
113
  with st.chat_message("user"):
114
  st.markdown(prompt)
115
 
 
128
  top_p=0.95,
129
  stream=False,
130
  )
131
+
132
  reply = response.choices[0].message["content"]
133
  st.markdown(reply)
134
+ chat_history.append(
135
  {"role": "assistant", "content": reply}
136
  )
137
+ conv["prev_text"] = reply
138
+
 
 
139
  except Exception as e:
140
  st.error(f"Error: {e}")
141
 
142
  # Edit features
143
+ if chat_history and chat_history[-1]["role"] == "assistant":
144
  # button
145
  with st.chat_message("assistant"):
146
  col_spacer, col_edit = st.columns([0.93, 0.07])
147
  with col_edit:
148
+ if not conv["editable"]:
149
  if st.button("✏️", key="edit_btn", help="Edit response"):
150
+ conv["editable"] = True
151
  st.rerun()
152
+
153
  ######## Edit mode #########
154
+ if conv["editable"]:
155
  st.markdown('<div class="editable-on">', unsafe_allow_html=True)
156
  with st.chat_message("assistant"):
157
  st.caption("Editing the last response…")
158
  with st.form("edit_form", clear_on_submit=False):
159
+ conv["edited_text"] = st.text_area(
160
  " ",
161
+ value=conv["prev_text"],
162
  height=500,
163
  label_visibility="collapsed",
164
  key="edit_textarea",
 
171
  st.markdown("</div>", unsafe_allow_html=True)
172
 
173
  # === Handle edit submission (backend: in-place continuation) ===
174
+ if conv["editable"] and finished_edit:
175
+ if chat_history and chat_history[-1]["role"] == "assistant":
176
+ chat_history.pop()
177
+ removed_text, added_text = summarize_edit(conv["prev_text"], conv["edited_text"])
178
+
179
+ # Save to edit history
180
+ import datetime
181
+ diff_chunks = get_detailed_diff(conv["prev_text"], conv["edited_text"])
182
+ conv["edit_history"].append({
183
+ "removed": removed_text,
184
+ "added": added_text,
185
+ "timestamp": datetime.datetime.now().strftime("%H:%M:%S"),
186
+ "original": conv["prev_text"],
187
+ "edited": conv["edited_text"],
188
+ "diff_chunks": diff_chunks
189
+ })
190
+
191
  system_prompt = "The user edited your previous answer. Only continue from the assistant message above. Do NOT rewrite or repeat it."
192
+
193
  # Exit edit mode before generation
194
+ conv["editable"] = False
195
 
196
  with st.chat_message("assistant"):
197
  with st.spinner("Continuing from your edit…"):
 
199
  resp = client.chat_completion(
200
  messages=[
201
  {"role": "system", "content": system_prompt},
202
+ {"role": "assistant", "content": conv["edited_text"]},
203
  {"role": "user", "content": "Continue exactly from the assistant message above. Do not restate any of it; just append."},
204
  ],
205
+
206
  max_tokens=60000,
207
  temperature=0.7,
208
  top_p=0.95,
209
  )
210
 
211
  generated = resp.choices[0].message["content"]
212
+ combined = conv["edited_text"] + '\n\n'+generated
213
+ conv["prev_text"] = combined
214
+ chat_history.append(
 
 
215
  {"role": "assistant", "content": combined}
216
  )
 
217
  st.rerun()
218
 
219
  except Exception as e:
220
  st.error(f"Error while continuing from edit: {e}")
221
+ chat_history.append(
222
+ {"role": "assistant", "content": conv["prev_text"]}
223
  )
224
+ conv["editable"] = False
streamlit_app.py CHANGED
@@ -3,22 +3,35 @@ import streamlit as st
3
  import pandas as pd
4
  import numpy as np
5
  from pathlib import Path
 
6
 
7
  from cards import (
8
- # widgets_card,
9
- # text_card,
10
- # dataframe_card,
11
- # charts_card,
12
- # media_card,
13
- # layouts_card,
14
  chat_card,
15
- inplace_chat_card,
16
- # status_card
17
  )
18
 
 
19
  if "init" not in st.session_state:
20
  st.session_state.init = True
 
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  pages = [
24
  st.Page(
@@ -30,55 +43,160 @@ pages = [
30
  "chat.py",
31
  title="Chat",
32
  icon=":material/chat:"
33
- ),
34
- # st.Page(
35
- # "home.py",
36
- # title="Home",
37
- # icon=":material/home:"
38
- # ),
39
- # st.Page(
40
- # "widgets.py",
41
- # title="Widgets",
42
- # icon=":material/widgets:"
43
- # ),
44
- # st.Page(
45
- # "text.py",
46
- # title="Text",
47
- # icon=":material/article:"
48
- # ),
49
- # st.Page(
50
- # "data.py",
51
- # title="Data",
52
- # icon=":material/table:"
53
- # ),
54
- # st.Page(
55
- # "charts.py",
56
- # title="Charts",
57
- # icon=":material/insert_chart:"
58
- # ),
59
- # st.Page(
60
- # "media.py",
61
- # title="Media",
62
- # icon=":material/image:"
63
- # ),
64
- # st.Page(
65
- # "layouts.py",
66
- # title="Layouts",
67
- # icon=":material/dashboard:"
68
- # ),
69
- # st.Page(
70
- # "status.py",
71
- # title="Status",
72
- # icon=":material/error:"
73
- # ),
74
  ]
75
 
76
  page = st.navigation(pages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  page.run()
78
 
79
- with st.sidebar.container(height=310):
80
- st.write("This is a simple demo for CSED499 Inplace feedback.")
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  st.sidebar.caption(
83
  "This app uses [Space Grotesk](https://fonts.google.com/specimen/Space+Grotesk) "
84
  "and [Space Mono](https://fonts.google.com/specimen/Space+Mono) fonts."
 
3
  import pandas as pd
4
  import numpy as np
5
  from pathlib import Path
6
+ import uuid
7
 
8
  from cards import (
 
 
 
 
 
 
9
  chat_card,
10
+ inplace_chat_card
 
11
  )
12
 
13
+ # ---------- Global state init ----------
14
  if "init" not in st.session_state:
15
  st.session_state.init = True
16
+ st.session_state.conversations = {} # {chat_id: {"title": str, "messages": [...]}}
17
+ st.session_state.current_chat_id = None # which conversation is open
18
 
19
+ def create_new_conversation():
20
+ chat_id = str(uuid.uuid4())
21
+ st.session_state.conversations[chat_id] = {
22
+ "title": "New chat",
23
+ "messages": [],
24
+ "editable": False,
25
+ "prev_text": "",
26
+ "edited_text": "",
27
+ "original_user_prompt": "",
28
+ "edit_history": []
29
+ }
30
+ st.session_state.current_chat_id = chat_id
31
+
32
+ # ensure at least one conversation exists
33
+ if not st.session_state.conversations:
34
+ create_new_conversation()
35
 
36
  pages = [
37
  st.Page(
 
43
  "chat.py",
44
  title="Chat",
45
  icon=":material/chat:"
46
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  ]
48
 
49
  page = st.navigation(pages)
50
+ # ---------- Sidebar: ChatGPT-style history ----------
51
+ with st.sidebar:
52
+ # new chat button
53
+ st.write("This is a simple demo for CSED499 Inplace feedback.")
54
+ st.button(
55
+ "+ New chat",
56
+ use_container_width=True,
57
+ on_click=create_new_conversation,
58
+ )
59
+ st.markdown("---")
60
+ st.markdown("### Chat History")
61
+
62
+ # Add custom CSS for hover effect on chat history items
63
+ st.markdown("""
64
+ <style>
65
+ /* Chat history item buttons */
66
+ .stButton > button[kind="secondary"] {
67
+ background-color: transparent;
68
+ border: none;
69
+ color: inherit;
70
+ padding: 8px 12px;
71
+ text-align: left;
72
+ width: 100%;
73
+ border-radius: 8px;
74
+ transition: background-color 0.2s;
75
+ justify-content: flex-start;
76
+ }
77
+ .stButton > button[kind="secondary"]:hover {
78
+ background-color: rgba(128, 128, 128, 0.2);
79
+ }
80
+ .stButton > button[kind="secondary"]:focus {
81
+ box-shadow: none;
82
+ }
83
+ /* Delete button styling */
84
+ .stButton > button[kind="secondary"].delete-btn {
85
+ width: auto;
86
+ padding: 4px 8px;
87
+ color: rgba(128, 128, 128, 0.6);
88
+ }
89
+ .stButton > button[kind="secondary"].delete-btn:hover {
90
+ color: rgba(128, 128, 128, 1);
91
+ background-color: rgba(128, 128, 128, 0.15);
92
+ }
93
+ </style>
94
+ """, unsafe_allow_html=True)
95
+
96
+ # list existing conversations as clickable text
97
+ for chat_id, conv in st.session_state.conversations.items():
98
+ # truncate title like ChatGPT
99
+ label = conv["title"]
100
+ if len(label) > 35:
101
+ label = label[:35] + "…"
102
+
103
+ # Create columns for chat item and delete button
104
+ col_chat, col_delete = st.columns([0.85, 0.15])
105
+
106
+ with col_chat:
107
+ # highlight current chat with a marker
108
+ if chat_id == st.session_state.current_chat_id:
109
+ st.markdown(f"**▶ {label}**")
110
+ else:
111
+ # Make entire text clickable with hover effect
112
+ if st.button(label, key=f"chat-{chat_id}", type="secondary", use_container_width=True):
113
+ st.session_state.current_chat_id = chat_id
114
+ st.rerun()
115
+
116
+ with col_delete:
117
+ # Delete button - only show for non-active chats or show for all
118
+ if st.button("✕", key=f"delete-{chat_id}", type="secondary", help="Delete chat"):
119
+ # If deleting current chat, switch to another one first
120
+ if chat_id == st.session_state.current_chat_id:
121
+ remaining = [cid for cid in st.session_state.conversations.keys() if cid != chat_id]
122
+ if remaining:
123
+ st.session_state.current_chat_id = remaining[0]
124
+ else:
125
+ st.session_state.current_chat_id = None
126
+
127
+ # Delete the conversation
128
+ del st.session_state.conversations[chat_id]
129
+ st.rerun()
130
+
131
+ # run selected page (after sidebar is defined)
132
  page.run()
133
 
 
 
134
 
135
+ # === Edit History Panel ===
136
+ st.sidebar.markdown("### Edit History")
137
+
138
+ if st.session_state.current_chat_id:
139
+ current_conv = st.session_state.conversations.get(st.session_state.current_chat_id, {})
140
+ edit_history = current_conv.get("edit_history", [])
141
+
142
+ if edit_history:
143
+ st.sidebar.markdown("""
144
+ <style>
145
+ .diff-container {
146
+ font-family: 'Space Mono', monospace;
147
+ font-size: 0.85em;
148
+ line-height: 1.4;
149
+ margin: 8px 0;
150
+ padding: 8px;
151
+ background-color: rgba(128, 128, 128, 0.05);
152
+ border-radius: 4px;
153
+ }
154
+ .diff-delete {
155
+ background-color: rgba(255, 0, 0, 0.15);
156
+ color: #d73a49;
157
+ text-decoration: line-through;
158
+ padding: 2px 4px;
159
+ border-radius: 2px;
160
+ }
161
+ .diff-insert {
162
+ background-color: rgba(0, 255, 0, 0.15);
163
+ color: #22863a;
164
+ padding: 2px 4px;
165
+ border-radius: 2px;
166
+ }
167
+ .diff-equal {
168
+ color: inherit;
169
+ }
170
+ </style>
171
+ """, unsafe_allow_html=True)
172
+
173
+ for idx, edit in enumerate(reversed(edit_history)):
174
+ edit_num = len(edit_history) - idx
175
+ # Each edit as a collapsible expander
176
+ with st.sidebar.expander(f"Edit #{edit_num} at {edit['timestamp']}", expanded=False):
177
+ # Build the diff HTML
178
+ diff_html = "<div class='diff-container'>"
179
+ for chunk in edit.get('diff_chunks', []):
180
+ text = chunk['text'].replace('<', '&lt;').replace('>', '&gt;')
181
+ if chunk['tag'] == 'delete':
182
+ diff_html += f"<span class='diff-delete'>{text}</span>"
183
+ elif chunk['tag'] == 'insert':
184
+ diff_html += f"<span class='diff-insert'>{text}</span>"
185
+ else: # equal
186
+ # Truncate long unchanged sections
187
+ if len(text) > 100:
188
+ text = text[:50] + "..." + text[-50:]
189
+ diff_html += f"<span class='diff-equal'>{text}</span>"
190
+ diff_html += "</div>"
191
+
192
+ st.markdown(diff_html, unsafe_allow_html=True)
193
+ else:
194
+ st.sidebar.write("No edits yet in this conversation.")
195
+ else:
196
+ st.sidebar.write("No conversation selected.")
197
+
198
+
199
+ # st.markdown("---")
200
  st.sidebar.caption(
201
  "This app uses [Space Grotesk](https://fonts.google.com/specimen/Space+Grotesk) "
202
  "and [Space Mono](https://fonts.google.com/specimen/Space+Mono) fonts."