Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ async def google_authorized(request: Request) -> RedirectResponse:
user_info = token.get("userinfo")

if user_info:

user_data = {
'id': user_info.get('id') or user_info.get('sub'),
'email': user_info.get('email'),
Expand All @@ -163,7 +162,7 @@ async def google_authorized(request: Request) -> RedirectResponse:
if handler:
api_token = secrets.token_urlsafe(32) # ~43 chars, hard to guess

# call the registered handler (await if async)
# Call the registered handler (await if async)
await handler('google', user_data, api_token)

Comment on lines +165 to 167
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t unconditionally await the callback handler (support sync and async).

await handler(...) will raise if the handler is synchronous. Detect coroutine functions and call accordingly.

Apply this diff:

-                # Call the registered handler (await if async)
-                await handler('google', user_data, api_token)
+                # Call the registered handler (await if async)
+                if inspect.iscoroutinefunction(handler):
+                    await handler('google', user_data, api_token)
+                else:
+                    handler('google', user_data, api_token)

Add the missing import near the top-level imports:

import inspect
🤖 Prompt for AI Agents
In api/routes/auth.py around lines 165 to 167, the code unconditionally does
`await handler(...)` which will fail for synchronous handlers; add `import
inspect` to the top-level imports and replace the unconditional await with a
conditional call that checks `inspect.iscoroutinefunction(handler)` (or
`inspect.isawaitable(...)` if passing a returned value) and `await`s only when
appropriate, otherwise call the handler synchronously and use its return value.

redirect = RedirectResponse(url="/chat", status_code=302)
Expand All @@ -176,9 +175,16 @@ async def google_authorized(request: Request) -> RedirectResponse:

return redirect

# Handler not set - log and raise error to prevent silent failure
logging.error("Google OAuth callback handler not registered in app state")
raise HTTPException(status_code=500, detail="Authentication handler not configured")

Comment on lines +178 to +181
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

HTTPException raised inside try is swallowed by the broad except below.

Raising here gets caught by the later except Exception and re-mapped to 400, masking the intended 500. Either narrow the try scope or re-raise HTTPException in the except block (see next comment’s diff).

🧰 Tools
🪛 Ruff (0.12.2)

180-180: Abstract raise to an inner function

(TRY301)

🤖 Prompt for AI Agents
In api/routes/auth.py around lines 178 to 181, the code raises an HTTPException
when the Google OAuth callback handler is missing but that exception is
swallowed by a later broad except which maps everything to a 400; narrow the try
block so the HTTPException is raised outside of the broad except, or modify the
except to detect and re-raise HTTPException (e.g., if isinstance(e,
HTTPException): raise) so the original 500 status is preserved.

# If we reach here, user_info was falsy
logging.warning("No user info received from Google OAuth")
raise HTTPException(status_code=400, detail="Failed to get user info from Google")

Comment on lines +183 to 185
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Same masking risk for the “no user info” branch.

This HTTPException(400, ...) is also intercepted by the broad except Exception. Ensure HTTPException is re-raised unchanged in the except block.

🧰 Tools
🪛 Ruff (0.12.2)

184-184: Abstract raise to an inner function

(TRY301)

🤖 Prompt for AI Agents
In api/routes/auth.py around lines 183 to 185, the HTTPException("Failed to get
user info from Google") thrown when no user info is returned can be swallowed by
the surrounding broad except Exception; update the exception handling so that
inside the except block you detect if the caught exception is an instance of
HTTPException and re-raise it unchanged (e.g., if isinstance(e, HTTPException):
raise), otherwise handle/log and raise or return the converted error as
currently implemented.

except Exception as e:
logging.error(f"Google OAuth authentication failed: {str(e)}")
raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")

Comment on lines 186 to 189
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use logging.exception, avoid f-strings in logs, and don’t blanket-convert all errors to 400.

  • Switch to logging.exception("...", %s) for proper traceback and lazy formatting.
  • Preserve earlier HTTPExceptions; return 500 for unexpected errors to avoid leaking internals as 400.

Apply this diff:

-    except Exception as e:
-        logging.error(f"Google OAuth authentication failed: {str(e)}")
-        raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
+    except HTTPException:
+        # Preserve earlier explicit HTTP errors (e.g., 400/500 above)
+        raise
+    except Exception as e:
+        logging.exception("Google OAuth authentication failed: %s", e)
+        raise HTTPException(status_code=500, detail="Authentication failed")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception as e:
logging.error(f"Google OAuth authentication failed: {str(e)}")
raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
except HTTPException:
# Preserve earlier explicit HTTP errors (e.g., 400/500 above)
raise
except Exception as e:
logging.exception("Google OAuth authentication failed: %s", e)
raise HTTPException(status_code=500, detail="Authentication failed")
🧰 Tools
🪛 Ruff (0.12.2)

186-186: Do not catch blind exception: Exception

(BLE001)


187-187: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


187-187: Use explicit conversion flag

Replace with conversion flag

(RUF010)


188-188: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


188-188: Use explicit conversion flag

Replace with conversion flag

(RUF010)

🤖 Prompt for AI Agents
In api/routes/auth.py around lines 186 to 189, replace the current broad except
block that uses f-strings and always converts errors to HTTP 400; instead catch
HTTPException first and re-raise it unchanged, and for other exceptions call
logging.exception with a format string and arguments (no f-strings) to include
the traceback, then raise an HTTPException with status_code=500 and a generic
detail (do not expose internal error text).


Expand Down Expand Up @@ -232,21 +238,19 @@ async def github_authorized(request: Request) -> RedirectResponse:
break

if user_info:

user_data = {
'id': user_info.get('id'),
'email': user_info.get('email'),
'email': email,
'name': user_info.get('name'),
'picture': user_info.get('avatar_url'),
}

# Call the registered GitHub callback handler if it exists to store user data.
handler = getattr(request.app.state, "callback_handler", None)
if handler:

api_token = secrets.token_urlsafe(32) # ~43 chars, hard to guess

# call the registered handler (await if async)
# Call the registered handler (await if async)
await handler('github', user_data, api_token)

Comment on lines +253 to 255
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Mirror async/sync-safe handler invocation for GitHub.

Same issue as Google flow: don’t assume the handler is async.

Apply this diff:

-                # Call the registered handler (await if async)
-                await handler('github', user_data, api_token)
+                # Call the registered handler (await if async)
+                if inspect.iscoroutinefunction(handler):
+                    await handler('github', user_data, api_token)
+                else:
+                    handler('github', user_data, api_token)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Call the registered handler (await if async)
await handler('github', user_data, api_token)
# Call the registered handler (await if async)
if inspect.iscoroutinefunction(handler):
await handler('github', user_data, api_token)
else:
handler('github', user_data, api_token)
🤖 Prompt for AI Agents
In api/routes/auth.py around lines 253-255, the code unconditionally awaits the
GitHub handler (await handler('github', user_data, api_token)), which assumes
the handler is async; change it to a sync/async-safe invocation like in the
Google flow: call the handler and if the return value is awaitable then await it
(or detect coroutine function before calling), ensuring you import/use
inspect.isawaitable or inspect.iscoroutinefunction as in the Google handler path
so both sync and async handlers are supported.

redirect = RedirectResponse(url="/chat", status_code=302)
Expand All @@ -259,9 +263,16 @@ async def github_authorized(request: Request) -> RedirectResponse:

return redirect

# Handler not set - log and raise error to prevent silent failure
logging.error("GitHub OAuth callback handler not registered in app state")
raise HTTPException(status_code=500, detail="Authentication handler not configured")

Comment on lines +267 to +269
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Same masking risk: handler-not-configured 500 gets turned into 400 by the blanket except.

Address by preserving HTTPException in the except block (see diff below in Lines 275–277).

🧰 Tools
🪛 Ruff (0.12.2)

268-268: Abstract raise to an inner function

(TRY301)

🤖 Prompt for AI Agents
In api/routes/auth.py around lines 267-269 (and the surrounding except at
~275-277), the blanket except is masking HTTPException and converting configured
500s into 400s; update the except handler so that if the caught exception is an
instance of fastapi.HTTPException you re-raise it unchanged, otherwise log the
error and raise a new HTTPException(status_code=500, detail="Authentication
handler not configured") — preserve original exception types by checking
isinstance(exc, HTTPException) and re-raising when true.

# If we reach here, user_info was falsy
logging.warning("No user info received from GitHub OAuth")
raise HTTPException(status_code=400, detail="Failed to get user info from Github")

except Exception as e:
logging.error(f"GitHub OAuth authentication failed: {str(e)}")
raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")

Comment on lines +275 to 277
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use logging.exception and avoid swallowing prior HTTPExceptions; prefer 500 for unexpected errors.

Same improvements as Google branch.

Apply this diff:

-    except Exception as e:
-        logging.error(f"GitHub OAuth authentication failed: {str(e)}")
-        raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
+    except HTTPException:
+        raise
+    except Exception as e:
+        logging.exception("GitHub OAuth authentication failed: %s", e)
+        raise HTTPException(status_code=500, detail="Authentication failed")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logging.error(f"GitHub OAuth authentication failed: {str(e)}")
raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}")
except HTTPException:
raise
except Exception as e:
logging.exception("GitHub OAuth authentication failed: %s", e)
raise HTTPException(status_code=500, detail="Authentication failed")
🧰 Tools
🪛 Ruff (0.12.2)

275-275: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


275-275: Use explicit conversion flag

Replace with conversion flag

(RUF010)


276-276: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


276-276: Use explicit conversion flag

Replace with conversion flag

(RUF010)

🤖 Prompt for AI Agents
In api/routes/auth.py around lines 275 to 277, replace the current except
handling that logs with logging.error and always raises HTTPException(400) so
that you use logging.exception(...) to capture the stack trace and avoid
swallowing pre-existing HTTPExceptions; specifically, in the except block, if
the caught exception is already an HTTPException re-raise it unchanged,
otherwise log the full exception with logging.exception and raise a new
HTTPException with status_code=500 and a concise detail message like
"Authentication failed: {err}".


Expand Down
69 changes: 48 additions & 21 deletions api/routes/graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from fastapi import APIRouter, Request, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from redis import ResponseError
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Incorrect ResponseError import.

redis-py exposes ResponseError under redis.exceptions.

-from redis import ResponseError
+from redis.exceptions import ResponseError
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from redis import ResponseError
from redis.exceptions import ResponseError
🤖 Prompt for AI Agents
In api/routes/graphs.py around line 11 the code imports ResponseError from the
top-level redis module which is incorrect; update the import to use
redis.exceptions.ResponseError (i.e., replace the current import with one that
imports ResponseError from redis.exceptions) so the correct exception class from
redis-py is referenced.


from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent
from api.auth.user_management import token_required
Expand Down Expand Up @@ -84,10 +85,25 @@ def sanitize_query(query: str) -> str:
return query.replace('\n', ' ').replace('\r', ' ')[:500]

def sanitize_log_input(value: str) -> str:
"""Sanitize input for safe logging (remove newlines and carriage returns)."""
"""
Sanitize input for safe logging—remove newlines,
carriage returns, tabs, and wrap in repr().
"""
if not isinstance(value, str):
return str(value)
return value.replace('\n', ' ').replace('\r', ' ')
value = str(value)

return value.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')

def _graph_name(request: Request, graph_id:str) -> str:
if not graph_id or not isinstance(graph_id, str):
raise HTTPException(status_code=400, detail="Invalid graph_id")

graph_id = graph_id.strip()[:200]
if not graph_id:
raise HTTPException(status_code=400,
detail="Invalid graph_id, must be less than 200 characters.")

return f"{request.state.user_id}_{graph_id}"

@graphs_router.get("")
@token_required
Expand All @@ -112,12 +128,7 @@ async def get_graph_data(request: Request, graph_id: str):
Nodes contain a minimal set of properties (id, name, labels, props).
Edges contain source and target node names (or internal ids), type and props.
"""
if not graph_id or not isinstance(graph_id, str):
return JSONResponse(content={"error": "Invalid graph_id"}, status_code=400)

graph_id = graph_id.strip()[:200]
namespaced = f"{request.state.user_id}_{graph_id}"

namespaced = _graph_name(request, graph_id)
try:
graph = db.select_graph(namespaced)
except Exception as e:
Expand Down Expand Up @@ -269,16 +280,7 @@ async def query_graph(request: Request, graph_id: str, chat_data: ChatRequest):
"""
text2sql
"""
# Input validation
if not graph_id or not isinstance(graph_id, str):
raise HTTPException(status_code=400, detail="Invalid graph_id")

# Sanitize graph_id to prevent injection
graph_id = graph_id.strip()[:100] # Limit length and strip whitespace
if not graph_id:
raise HTTPException(status_code=400, detail="Invalid graph_id")

graph_id = f"{request.state.user_id}_{graph_id}"
graph_id = _graph_name(request, graph_id)

queries_history = chat_data.chat if hasattr(chat_data, 'chat') else None
result_history = chat_data.result if hasattr(chat_data, 'result') else None
Expand Down Expand Up @@ -553,7 +555,8 @@ async def confirm_destructive_operation(
"""
Handle user confirmation for destructive SQL operations
"""
graph_id = f"{request.state.user_id}_{graph_id.strip()}"

graph_id = _graph_name(request, graph_id)

if hasattr(confirm_data, 'confirmation'):
confirmation = confirm_data.confirmation.strip().upper()
Expand Down Expand Up @@ -674,7 +677,7 @@ async def refresh_graph_schema(request: Request, graph_id: str):
This endpoint allows users to manually trigger a schema refresh
if they suspect the graph is out of sync with the database.
"""
graph_id = f"{request.state.user_id}_{graph_id.strip()}"
graph_id = _graph_name(request, graph_id)

try:
# Get database connection details
Expand Down Expand Up @@ -716,3 +719,27 @@ async def refresh_graph_schema(request: Request, graph_id: str):
"success": False,
"error": "Error refreshing schema"
}, status_code=500)

@graphs_router.delete("/{graph_id}")
@token_required
async def delete_graph(request: Request, graph_id: str):
"""Delete the specified graph (namespaced to the user).

This will attempt to delete the FalkorDB graph belonging to the
authenticated user. The graph id used by the client is stripped of
namespace and will be namespaced using the user's id from the request
state.
"""
namespaced = _graph_name(request, graph_id)

try:
# Select and delete the graph using the FalkorDB client API
graph = db.select_graph(namespaced)
await graph.delete()
return JSONResponse(content={"success": True, "graph": graph_id})
except ResponseError:
return JSONResponse(content={"error": "Failed to delete graph, Graph not found"},
status_code=404)
except Exception as e:
logging.exception("Failed to delete graph %s: %s", sanitize_log_input(namespaced), e)
return JSONResponse(content={"error": "Failed to delete graph"}, status_code=500)
88 changes: 88 additions & 0 deletions app/public/css/menu.css
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,91 @@
height: 16px;
flex-shrink: 0;
}

/* Graph custom dropdown (moved from chat_header.j2 inline styles) */
.graph-custom-dropdown {
position: relative;
display: inline-block;
width: 180px;
margin-left: 8px;
}

.graph-selected {
padding: 8px 14px;
border-radius: 6px;
background: var(--falkor-quaternary);
color: var(--text-primary);
cursor: pointer;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 160px;
box-sizing: border-box;
font-size: 14px;
}

.graph-options {
position: absolute;
top: calc(100%);
left: 0;
right: 0;
background: var(--falkor-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
border-top-left-radius: 0;
border-top-right-radius: 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 260px;
overflow: auto;
display: none;
z-index: 50;
}
Comment on lines +359 to +374
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Graph options panel z-index is too low; may render under other layers.

Align with .custom-dropdown (z-index: 1000) to avoid clipping.

 .graph-options {
   position: absolute;
   top: calc(100%);
   left: 0;
   right: 0;
   background: var(--falkor-secondary);
   border: 1px solid var(--border-color);
   border-radius: 6px;
   border-top-left-radius: 0;
   border-top-right-radius: 0;
   box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
   max-height: 260px;
   overflow: auto;
   display: none;
-  z-index: 50;
+  z-index: 1000;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.graph-options {
position: absolute;
top: calc(100%);
left: 0;
right: 0;
background: var(--falkor-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
border-top-left-radius: 0;
border-top-right-radius: 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 260px;
overflow: auto;
display: none;
z-index: 50;
}
.graph-options {
position: absolute;
top: calc(100%);
left: 0;
right: 0;
background: var(--falkor-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
border-top-left-radius: 0;
border-top-right-radius: 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 260px;
overflow: auto;
display: none;
z-index: 1000;
}
🤖 Prompt for AI Agents
In app/public/css/menu.css around lines 359 to 374, the .graph-options z-index
is set to 50 which can cause it to render beneath other UI layers; update its
z-index to match .custom-dropdown (z-index: 1000) so the panel appears above
other elements and avoids clipping.


.dropdown-option {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 8px 12px;
gap: 8px;
color: var(--text-primary);
cursor: pointer;
}

.dropdown-option:hover {
background: var(--bg-tertiary);
}

.dropdown-option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.dropdown-option .delete-btn {
background: transparent;
border: none;
color: #ff6b6b;
opacity: 0;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}

.dropdown-option:hover .delete-btn {
opacity: 1;
}

.dropdown-option .delete-btn svg {
width: 16px;
height: 16px;
}
Comment on lines +376 to +417
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Selector collision with global .dropdown-option. Scope styles to graph menu.

Prevent unintended overrides of other dropdowns.

-.dropdown-option {
+.graph-options .dropdown-option {
   display: flex;
   align-items: center;
   justify-content: flex-start;
   padding: 8px 12px;
   gap: 8px;
   color: var(--text-primary);
   cursor: pointer;
 }
 
-.dropdown-option:hover {
+.graph-options .dropdown-option:hover {
   background: var(--bg-tertiary);
 }
 
-.dropdown-option span {
+.graph-options .dropdown-option span {
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
 
-.dropdown-option .delete-btn {
+.graph-options .dropdown-option .delete-btn {
   background: transparent;
   border: none;
   color: #ff6b6b;
   opacity: 0;
   cursor: pointer;
   width: 28px;
   height: 28px;
   display: flex;
   align-items: center;
   justify-content: center;
   margin-left: auto;
 }
 
-.dropdown-option:hover .delete-btn {
+.graph-options .dropdown-option:hover .delete-btn {
   opacity: 1;
 }
 
-.dropdown-option .delete-btn svg {
+.graph-options .dropdown-option .delete-btn svg {
   width: 16px;
   height: 16px;
 }
🤖 Prompt for AI Agents
In app/public/css/menu.css around lines 376 to 417, the current .dropdown-option
rules are global and may collide with other dropdown styles; scope these
selectors to the graph menu by prefixing every rule with the graph menu
container class (e.g. .graph-menu .dropdown-option, .graph-menu
.dropdown-option:hover, .graph-menu .dropdown-option span, .graph-menu
.dropdown-option .delete-btn, etc.), update the hover and descendant selectors
accordingly so the styles only apply within the graph menu, and ensure you add
or use the correct container class in the HTML if it doesn't exist yet.


.graph-options.open {
display: block;
}
13 changes: 10 additions & 3 deletions app/templates/components/chat_header.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
<img src="/static/icons/queryweaver.webp" alt="Chat Logo" class="logo">
<h1>Natural Language to SQL Generator</h1>
<div class="button-container">
<select title="Select Database" id="graph-select">
<option value="">Loading...</option>
</select>
<!-- Custom dropdown to show per-item delete action on hover -->
<div id="graph-custom-dropdown" class="graph-custom-dropdown">
<div id="graph-selected" class="graph-selected dropdown-selected" title="Select Database">
<span class="dropdown-text">Select Database</span>
<span class="dropdown-arrow">▼</span>
</div>
<div id="graph-options" class="graph-options dropdown-options" aria-hidden="true"></div>
</div>
Comment on lines +6 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add proper a11y semantics and a real selected value (not display text).

The custom dropdown lacks keyboard support and ARIA roles, and the selected value is inferred from visible text, which will break when the placeholder (“Select Database”) is present. Store the selected value in a data attribute and apply combobox/listbox semantics.

Apply:

-        <div id="graph-custom-dropdown" class="graph-custom-dropdown">
-            <div id="graph-selected" class="graph-selected dropdown-selected" title="Select Database">
-                <span class="dropdown-text">Select Database</span>
-                <span class="dropdown-arrow">▼</span>
-            </div>
-            <div id="graph-options" class="graph-options dropdown-options" aria-hidden="true"></div>
-        </div>
+        <div id="graph-custom-dropdown" class="graph-custom-dropdown">
+            <div
+                id="graph-selected"
+                class="graph-selected dropdown-selected"
+                title="Select Database"
+                role="combobox"
+                aria-haspopup="listbox"
+                aria-expanded="false"
+                aria-controls="graph-options"
+                tabindex="0"
+                data-value=""
+            >
+                <span class="dropdown-text">Select Database</span>
+                <span class="dropdown-arrow" aria-hidden="true">▼</span>
+            </div>
+            <div id="graph-options" class="graph-options dropdown-options" role="listbox" aria-hidden="true"></div>
+        </div>

Follow-up: ensure the TS toggles aria-expanded/aria-hidden when opening/closing, and that getSelectedGraph() reads graph-selected.dataset.value instead of text.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<!-- Custom dropdown to show per-item delete action on hover -->
<div id="graph-custom-dropdown" class="graph-custom-dropdown">
<div id="graph-selected" class="graph-selected dropdown-selected" title="Select Database">
<span class="dropdown-text">Select Database</span>
<span class="dropdown-arrow">▼</span>
</div>
<div id="graph-options" class="graph-options dropdown-options" aria-hidden="true"></div>
</div>
<div id="graph-custom-dropdown" class="graph-custom-dropdown">
<div
id="graph-selected"
class="graph-selected dropdown-selected"
title="Select Database"
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="graph-options"
tabindex="0"
data-value=""
>
<span class="dropdown-text">Select Database</span>
<span class="dropdown-arrow" aria-hidden="true">▼</span>
</div>
<div id="graph-options" class="graph-options dropdown-options" role="listbox" aria-hidden="true"></div>
</div>


<!-- styles for the graph dropdown moved to app/public/css/menu.css -->
<div class="vertical-separator"></div>
<input title="Upload Schema" id="schema-upload" type="file" accept=".json" style="display: none;" tabindex="-1" disabled/>
<label for="schema-upload" id="custom-file-upload">
Expand Down
8 changes: 5 additions & 3 deletions app/ts/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DOM } from './modules/config';
import { initChat } from './modules/messages';
import { sendMessage, pauseRequest } from './modules/chat';
import { loadGraphs, handleFileUpload, onGraphChange } from './modules/graphs';
import { getSelectedGraph } from './modules/graph_select';
import {
toggleContainer,
showResetConfirmation,
Expand Down Expand Up @@ -64,7 +65,7 @@ function setupEventListeners() {

DOM.schemaButton?.addEventListener('click', () => {
toggleContainer(DOM.schemaContainer as HTMLElement, async () => {
const selected = DOM.graphSelect?.value;
const selected = getSelectedGraph();
if (!selected) return;
await loadAndShowGraph(selected);
});
Expand All @@ -84,9 +85,10 @@ function setupEventListeners() {
}
});

DOM.graphSelect?.addEventListener('change', async () => {
// Legacy select is hidden; custom UI will trigger load via graph_select helper
document.getElementById('graph-options')?.addEventListener('click', async () => {
onGraphChange();
const selected = DOM.graphSelect?.value;
const selected = getSelectedGraph();
if (!selected) return;
if (DOM.schemaContainer && DOM.schemaContainer.classList.contains('open')) {
await loadAndShowGraph(selected);
Expand Down
5 changes: 3 additions & 2 deletions app/ts/modules/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

import { DOM, state, MESSAGE_DELIMITER } from './config';
import { addMessage, removeLoadingMessage, moveLoadingMessageToBottom } from './messages';
import { getSelectedGraph } from './graph_select';

export async function sendMessage() {
const message = (DOM.messageInput?.value || '').trim();
if (!message) return;

const selectedValue = DOM.graphSelect?.value || '';
const selectedValue = getSelectedGraph() || '';
if (!selectedValue) {
addMessage('Please select a graph from the dropdown before sending a message.', false, true);
return;
Expand Down Expand Up @@ -257,7 +258,7 @@ export async function handleDestructiveConfirmation(confirmation: string, sqlQue
}

try {
const selectedValue = DOM.graphSelect?.value || '';
const selectedValue = getSelectedGraph() || '';

const response = await fetch('/graphs/' + encodeURIComponent(selectedValue) + '/confirm', {
method: 'POST',
Expand Down
2 changes: 0 additions & 2 deletions app/ts/modules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export const SELECTORS = {
leftToolbarInner: '#left-toolbar-inner',
expInstructions: '#instructions-textarea',
inputContainer: '#input-container',
graphSelect: '#graph-select',
resetConfirmationModal: '#reset-confirmation-modal',
resetConfirmBtn: '#reset-confirm-btn',
resetCancelBtn: '#reset-cancel-btn'
Expand Down Expand Up @@ -59,7 +58,6 @@ export const DOM = {
leftToolbarInner: getElement<HTMLElement | null>('left-toolbar-inner'),
expInstructions: getElement<HTMLTextAreaElement | null>('instructions-textarea'),
inputContainer: getElement<HTMLElement | null>('input-container'),
graphSelect: getElement<HTMLSelectElement | null>('graph-select'),
resetConfirmationModal: getElement<HTMLElement | null>('reset-confirmation-modal'),
resetConfirmBtn: getElement<HTMLButtonElement | null>('reset-confirm-btn'),
resetCancelBtn: getElement<HTMLButtonElement | null>('reset-cancel-btn')
Expand Down
Loading
Loading