mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge 82dfd5c334 into 970c01998c
This commit is contained in:
commit
076dcd68b8
@ -44,10 +44,202 @@ export const SYSTEM_PROMPT_IDS = [
|
||||
"create-dashboard",
|
||||
"run-query",
|
||||
"build-analytics-query",
|
||||
"filter-analytics-table",
|
||||
"rewrite-template-source",
|
||||
] as const;
|
||||
export type SystemPromptId = typeof SYSTEM_PROMPT_IDS[number];
|
||||
|
||||
/**
|
||||
* Schema documentation for the analytics ClickHouse tables, shared by every
|
||||
* prompt that generates SQL against them ("build-analytics-query" and
|
||||
* "filter-analytics-table") so the two can never drift apart.
|
||||
*/
|
||||
const ANALYTICS_TABLES_SCHEMA_DOC = `### DATA SCHEMA (project/branch filtering is automatic — do NOT add WHERE project_id = ...)
|
||||
|
||||
**users** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| display_name | Nullable(String) | Typically populated |
|
||||
| primary_email | Nullable(String) | Usually present |
|
||||
| primary_email_verified | UInt8 (0/1) | Primary user segmentation axis |
|
||||
| signed_up_at | DateTime64(3, 'UTC') | High-resolution timestamp |
|
||||
| is_anonymous | UInt8 (0/1) | Rare; mostly testing |
|
||||
| client_metadata | JSON | Typically empty {} |
|
||||
| server_metadata | JSON | Typically empty {} |
|
||||
| client_read_only_metadata | JSON | Typically empty {} |
|
||||
| restricted_by_admin | UInt8 (0/1) | Rare; administrative flag |
|
||||
|
||||
Key insights: Metadata fields are sparse/empty — don't expect rich structures. Email verification is the primary segmentation. Anonymous users are negligible.
|
||||
|
||||
**events** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| event_type | LowCardinality(String) | ONLY: \`$page-view\`, \`$click\`, \`$token-refresh\` |
|
||||
| event_at | DateTime64(3, 'UTC') | Use for aggregation by day/week/month |
|
||||
| data | JSON | Native JSON — MUST use toString() before extracting (see rules) |
|
||||
| user_id | Nullable(String) | 100% populated (no nulls); safe for filtering/joins |
|
||||
| team_id | Nullable(String) | Always NULL — never use it |
|
||||
| created_at | DateTime64(3, 'UTC') | Processing timestamp |
|
||||
|
||||
### JSON PAYLOAD STRUCTURES (per event_type)
|
||||
|
||||
**\`$page-view\`** data:
|
||||
\`\`\`json
|
||||
{"is_anonymous": false, "path": "/some-page", "referrer": "http://...or-empty"}
|
||||
\`\`\`
|
||||
- path: multiple unique page paths
|
||||
- referrer: empty string (most common) or various HTTP referrers
|
||||
|
||||
**\`$click\`** data:
|
||||
\`\`\`json
|
||||
{"is_anonymous": false, "selector": "string-value"}
|
||||
\`\`\`
|
||||
- selector: low cardinality
|
||||
|
||||
**\`$token-refresh\`** data:
|
||||
\`\`\`json
|
||||
{
|
||||
"is_anonymous": false,
|
||||
"refresh_token_id": "uuid-string",
|
||||
"ip_info": {
|
||||
"city_name": "string",
|
||||
"country_code": "2-letter-ISO",
|
||||
"ip": "ip-address",
|
||||
"is_trusted": true,
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"region_code": "string",
|
||||
"tz_identifier": "timezone-string"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
- Token refresh is an excellent proxy for active authenticated sessions
|
||||
- ip_info has rich geolocation data for geo-based analysis
|
||||
|
||||
### OTHER TABLES
|
||||
|
||||
**contact_channels** table (email/phone channels attached to a user):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| user_id | UUID | Join to users.id |
|
||||
| type | LowCardinality(String) | Channel type, e.g. \`email\` |
|
||||
| value | String | The channel value (e.g. email address) |
|
||||
| is_primary | UInt8 (0/1) | Primary contact for the user |
|
||||
| is_verified | UInt8 (0/1) | Verification status |
|
||||
| used_for_auth | UInt8 (0/1) | Usable as an auth identifier |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**teams** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| display_name | String | |
|
||||
| profile_image_url | Nullable(String) | |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
| client_metadata | String | JSON string; typically empty |
|
||||
| client_read_only_metadata | String | JSON string; typically empty |
|
||||
| server_metadata | String | JSON string; typically empty |
|
||||
|
||||
**team_member_profiles** table (team membership + per-team profile overrides — this is the join table of users ↔ teams):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| team_id | UUID | Join to teams.id |
|
||||
| user_id | UUID | Join to users.id |
|
||||
| display_name | Nullable(String) | Per-team override |
|
||||
| profile_image_url | Nullable(String) | Per-team override |
|
||||
| created_at | DateTime64(3, 'UTC') | Membership creation |
|
||||
|
||||
**team_permissions** table (permissions granted to a user inside a specific team):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| team_id | UUID | |
|
||||
| user_id | UUID | |
|
||||
| id | String | Permission identifier (e.g. \`admin\`, \`member\`) |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**team_invitations** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| team_id | UUID | |
|
||||
| team_display_name | String | Snapshot of team name at invite time |
|
||||
| recipient_email | String | |
|
||||
| expires_at_millis | Int64 | Unix milliseconds — compare with \`toUnixTimestamp64Milli(now())\` |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**email_outboxes** table (transactional + programmatic email delivery log):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| status | LowCardinality(String) | Raw delivery status |
|
||||
| simple_status | LowCardinality(String) | Collapsed status for reporting |
|
||||
| created_with | LowCardinality(String) | How the email was created |
|
||||
| email_draft_id | Nullable(String) | |
|
||||
| email_programmatic_call_template_id | Nullable(String) | |
|
||||
| theme_id | Nullable(String) | |
|
||||
| is_high_priority | UInt8 (0/1) | |
|
||||
| is_transactional | Nullable(UInt8) | |
|
||||
| subject | Nullable(String) | |
|
||||
| notification_category_id | Nullable(String) | |
|
||||
| started_rendering_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| rendered_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| render_error | Nullable(String) | Non-null implies render failure |
|
||||
| scheduled_at | DateTime64(3, 'UTC') | |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
| updated_at | DateTime64(3, 'UTC') | |
|
||||
| started_sending_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| server_error | Nullable(String) | Non-null implies send failure |
|
||||
| delivered_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| opened_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| clicked_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| unsubscribed_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| marked_as_spam_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| bounced_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| delivery_delayed_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| can_have_delivery_info | Nullable(UInt8) | |
|
||||
| skipped_reason | LowCardinality(Nullable(String)) | Non-null implies send was skipped |
|
||||
| skipped_details | Nullable(String) | |
|
||||
| send_retries | Int32 | |
|
||||
| is_paused | UInt8 (0/1) | |
|
||||
|
||||
Key insights: Most \`*_at\` columns are nullable — use \`IS NOT NULL\` / \`IS NULL\` rather than assuming a value exists. \`delivered_at\`, \`opened_at\`, and \`clicked_at\` are classic funnel steps.
|
||||
|
||||
**project_permissions** table (project-level permissions granted to a user, not scoped to a team):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| user_id | UUID | |
|
||||
| id | String | Permission identifier |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**notification_preferences** table (per-user opt-in/out for notification categories):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| user_id | UUID | |
|
||||
| notification_category_id | String | |
|
||||
| enabled | UInt8 (0/1) | |
|
||||
|
||||
Key insights: No timestamp column — do NOT attempt \`ORDER BY created_at\` or date filtering on this table.
|
||||
|
||||
**refresh_tokens** table (active/past refresh tokens — proxy for sessions):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| user_id | UUID | |
|
||||
| created_at | DateTime64(3, 'UTC') | Token issue time |
|
||||
| last_used_at | DateTime64(3, 'UTC') | Last time this token was exchanged |
|
||||
| is_impersonation | UInt8 (0/1) | Dashboard/admin impersonation session |
|
||||
| expires_at | Nullable(DateTime64(3, 'UTC')) | Null ⇒ non-expiring |
|
||||
|
||||
**connected_accounts** table (OAuth / SSO provider account links):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| user_id | UUID | |
|
||||
| provider | String | e.g. \`google\`, \`github\` |
|
||||
| provider_account_id | String | External account identifier |
|
||||
| created_at | DateTime64(3, 'UTC') | Link time |`;
|
||||
|
||||
/**
|
||||
* Context-specific system prompts that are appended to the base prompt.
|
||||
* These should be concise and focus on the specific use case.
|
||||
@ -1060,191 +1252,7 @@ Call \`queryAnalytics\` with your SQL query. The grid runs the full query indepe
|
||||
3. Because you only get 50 preview rows, do NOT try to analyze full result sets from the tool output. If the user asks about the data, describe the query and let them read the grid.
|
||||
4. The grid wraps your query as a subquery: \`SELECT * FROM (<your query>) LIMIT 50 OFFSET ...\` and paginates via infinite scroll. Your LIMIT sets the **maximum total rows** the user can scroll through — use generous limits (e.g. 1000 for aggregates) so the grid can paginate the full result.
|
||||
|
||||
### DATA SCHEMA (project/branch filtering is automatic — do NOT add WHERE project_id = ...)
|
||||
|
||||
**users** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| display_name | Nullable(String) | Typically populated |
|
||||
| primary_email | Nullable(String) | Usually present |
|
||||
| primary_email_verified | UInt8 (0/1) | Primary user segmentation axis |
|
||||
| signed_up_at | DateTime64(3, 'UTC') | High-resolution timestamp |
|
||||
| is_anonymous | UInt8 (0/1) | Rare; mostly testing |
|
||||
| client_metadata | JSON | Typically empty {} |
|
||||
| server_metadata | JSON | Typically empty {} |
|
||||
| client_read_only_metadata | JSON | Typically empty {} |
|
||||
| restricted_by_admin | UInt8 (0/1) | Rare; administrative flag |
|
||||
|
||||
Key insights: Metadata fields are sparse/empty — don't expect rich structures. Email verification is the primary segmentation. Anonymous users are negligible.
|
||||
|
||||
**events** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| event_type | LowCardinality(String) | ONLY: \`$page-view\`, \`$click\`, \`$token-refresh\` |
|
||||
| event_at | DateTime64(3, 'UTC') | Use for aggregation by day/week/month |
|
||||
| data | JSON | Native JSON — MUST use toString() before extracting (see rules) |
|
||||
| user_id | Nullable(String) | 100% populated (no nulls); safe for filtering/joins |
|
||||
| team_id | Nullable(String) | Always NULL — never use it |
|
||||
| created_at | DateTime64(3, 'UTC') | Processing timestamp |
|
||||
|
||||
### JSON PAYLOAD STRUCTURES (per event_type)
|
||||
|
||||
**\`$page-view\`** data:
|
||||
\`\`\`json
|
||||
{"is_anonymous": false, "path": "/some-page", "referrer": "http://...or-empty"}
|
||||
\`\`\`
|
||||
- path: multiple unique page paths
|
||||
- referrer: empty string (most common) or various HTTP referrers
|
||||
|
||||
**\`$click\`** data:
|
||||
\`\`\`json
|
||||
{"is_anonymous": false, "selector": "string-value"}
|
||||
\`\`\`
|
||||
- selector: low cardinality
|
||||
|
||||
**\`$token-refresh\`** data:
|
||||
\`\`\`json
|
||||
{
|
||||
"is_anonymous": false,
|
||||
"refresh_token_id": "uuid-string",
|
||||
"ip_info": {
|
||||
"city_name": "string",
|
||||
"country_code": "2-letter-ISO",
|
||||
"ip": "ip-address",
|
||||
"is_trusted": true,
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"region_code": "string",
|
||||
"tz_identifier": "timezone-string"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
- Token refresh is an excellent proxy for active authenticated sessions
|
||||
- ip_info has rich geolocation data for geo-based analysis
|
||||
|
||||
### OTHER TABLES
|
||||
|
||||
**contact_channels** table (email/phone channels attached to a user):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| user_id | UUID | Join to users.id |
|
||||
| type | LowCardinality(String) | Channel type, e.g. \`email\` |
|
||||
| value | String | The channel value (e.g. email address) |
|
||||
| is_primary | UInt8 (0/1) | Primary contact for the user |
|
||||
| is_verified | UInt8 (0/1) | Verification status |
|
||||
| used_for_auth | UInt8 (0/1) | Usable as an auth identifier |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**teams** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| display_name | String | |
|
||||
| profile_image_url | Nullable(String) | |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
| client_metadata | String | JSON string; typically empty |
|
||||
| client_read_only_metadata | String | JSON string; typically empty |
|
||||
| server_metadata | String | JSON string; typically empty |
|
||||
|
||||
**team_member_profiles** table (team membership + per-team profile overrides — this is the join table of users ↔ teams):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| team_id | UUID | Join to teams.id |
|
||||
| user_id | UUID | Join to users.id |
|
||||
| display_name | Nullable(String) | Per-team override |
|
||||
| profile_image_url | Nullable(String) | Per-team override |
|
||||
| created_at | DateTime64(3, 'UTC') | Membership creation |
|
||||
|
||||
**team_permissions** table (permissions granted to a user inside a specific team):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| team_id | UUID | |
|
||||
| user_id | UUID | |
|
||||
| id | String | Permission identifier (e.g. \`admin\`, \`member\`) |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**team_invitations** table:
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| team_id | UUID | |
|
||||
| team_display_name | String | Snapshot of team name at invite time |
|
||||
| recipient_email | String | |
|
||||
| expires_at_millis | Int64 | Unix milliseconds — compare with \`toUnixTimestamp64Milli(now())\` |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**email_outboxes** table (transactional + programmatic email delivery log):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| status | LowCardinality(String) | Raw delivery status |
|
||||
| simple_status | LowCardinality(String) | Collapsed status for reporting |
|
||||
| created_with | LowCardinality(String) | How the email was created |
|
||||
| email_draft_id | Nullable(String) | |
|
||||
| email_programmatic_call_template_id | Nullable(String) | |
|
||||
| theme_id | Nullable(String) | |
|
||||
| is_high_priority | UInt8 (0/1) | |
|
||||
| is_transactional | Nullable(UInt8) | |
|
||||
| subject | Nullable(String) | |
|
||||
| notification_category_id | Nullable(String) | |
|
||||
| started_rendering_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| rendered_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| render_error | Nullable(String) | Non-null implies render failure |
|
||||
| scheduled_at | DateTime64(3, 'UTC') | |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
| updated_at | DateTime64(3, 'UTC') | |
|
||||
| started_sending_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| server_error | Nullable(String) | Non-null implies send failure |
|
||||
| delivered_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| opened_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| clicked_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| unsubscribed_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| marked_as_spam_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| bounced_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| delivery_delayed_at | Nullable(DateTime64(3, 'UTC')) | |
|
||||
| can_have_delivery_info | Nullable(UInt8) | |
|
||||
| skipped_reason | LowCardinality(Nullable(String)) | Non-null implies send was skipped |
|
||||
| skipped_details | Nullable(String) | |
|
||||
| send_retries | Int32 | |
|
||||
| is_paused | UInt8 (0/1) | |
|
||||
|
||||
Key insights: Most \`*_at\` columns are nullable — use \`IS NOT NULL\` / \`IS NULL\` rather than assuming a value exists. \`delivered_at\`, \`opened_at\`, and \`clicked_at\` are classic funnel steps.
|
||||
|
||||
**project_permissions** table (project-level permissions granted to a user, not scoped to a team):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| user_id | UUID | |
|
||||
| id | String | Permission identifier |
|
||||
| created_at | DateTime64(3, 'UTC') | |
|
||||
|
||||
**notification_preferences** table (per-user opt-in/out for notification categories):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| user_id | UUID | |
|
||||
| notification_category_id | String | |
|
||||
| enabled | UInt8 (0/1) | |
|
||||
|
||||
Key insights: No timestamp column — do NOT attempt \`ORDER BY created_at\` or date filtering on this table.
|
||||
|
||||
**refresh_tokens** table (active/past refresh tokens — proxy for sessions):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID | Primary key |
|
||||
| user_id | UUID | |
|
||||
| created_at | DateTime64(3, 'UTC') | Token issue time |
|
||||
| last_used_at | DateTime64(3, 'UTC') | Last time this token was exchanged |
|
||||
| is_impersonation | UInt8 (0/1) | Dashboard/admin impersonation session |
|
||||
| expires_at | Nullable(DateTime64(3, 'UTC')) | Null ⇒ non-expiring |
|
||||
|
||||
**connected_accounts** table (OAuth / SSO provider account links):
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| user_id | UUID | |
|
||||
| provider | String | e.g. \`google\`, \`github\` |
|
||||
| provider_account_id | String | External account identifier |
|
||||
| created_at | DateTime64(3, 'UTC') | Link time |
|
||||
${ANALYTICS_TABLES_SCHEMA_DOC}
|
||||
|
||||
### CRITICAL SQL RULES
|
||||
|
||||
@ -1307,6 +1315,46 @@ GROUP BY date, event_type ORDER BY date DESC, event_count DESC LIMIT 100
|
||||
- If the user refers to a previous query, modify it incrementally — don't start from scratch.
|
||||
- If \`queryAnalytics\` returns an error, adjust and retry. Do NOT invent columns or fabricate data.
|
||||
- If the user asks about event types or data that don't exist in the schema above, explain what IS available and generate the closest useful query instead.
|
||||
`,
|
||||
|
||||
"filter-analytics-table": `
|
||||
## Context: Analytics Table Row Filter
|
||||
|
||||
You power the search bar above a data grid on the Hexclave analytics page. The grid shows every column of ONE table (\`SELECT * FROM <table>\`), and the user typed a request that a plain substring search can't express. Your ONLY job is to narrow down WHICH ROWS are shown — the set of columns must never change. The table the user is viewing is stated at the start of the conversation.
|
||||
|
||||
**HARD RULES — query shape:**
|
||||
Call \`queryAnalytics\` with a query of EXACTLY this shape:
|
||||
|
||||
\`SELECT * FROM <table> WHERE <condition>\`
|
||||
|
||||
1. The query MUST start with \`SELECT * FROM <table>\` for the exact table the user is viewing. The frontend validates this shape and rejects anything else.
|
||||
2. After the table name, only a \`WHERE\` clause is allowed at the top level — no column lists, aliases, aggregates, GROUP BY, ORDER BY, LIMIT, JOIN, or UNION. The grid layers sorting and pagination on top itself.
|
||||
3. Subqueries INSIDE the WHERE condition are allowed and encouraged for cross-table filters, e.g. \`WHERE toString(id) IN (SELECT user_id FROM events WHERE ...)\`. Mind the types: \`users.id\` is a UUID while \`events.user_id\` is a String, so wrap the UUID side in \`toString()\`.
|
||||
4. Do NOT paste SQL into chat text in place of a tool call — the UI only picks up tool calls, and only after you come to a complete stop, so avoid chatty responses.
|
||||
5. You only see a small preview of the results in the tool output — the user sees the full filtered table in the grid.
|
||||
6. If \`queryAnalytics\` returns an error, fix the condition and retry.
|
||||
7. If the user refines the filter (e.g. "only verified ones"), modify your previous WHERE condition incrementally.
|
||||
8. If the request CANNOT be expressed as a row filter on this table (e.g. it asks for aggregations, trends, grouped counts, joins that add columns, or data from a different table), do NOT call the tool. Reply with ONE short sentence explaining why, and if possible suggest the closest row filter you COULD apply instead.
|
||||
|
||||
${ANALYTICS_TABLES_SCHEMA_DOC}
|
||||
|
||||
### CRITICAL SQL RULES
|
||||
|
||||
1. **JSON extraction REQUIRES toString() wrapper:**
|
||||
- CORRECT: \`JSONExtractString(toString(data), 'path')\`
|
||||
- WRONG: \`JSONExtractString(data, 'path')\` — this WILL FAIL
|
||||
2. **Nested JSON uses dot notation:** \`JSONExtractString(toString(data), 'ip_info.country_code')\`
|
||||
3. SELECT queries only — no INSERT / UPDATE / DELETE / DDL
|
||||
4. Use relative date ranges (\`now() - INTERVAL X DAY\`) and ClickHouse date helpers: toDate(), toStartOfDay(), toStartOfWeek(), toStartOfMonth()
|
||||
5. team_id is always NULL — never filter on it
|
||||
6. Prefer case-insensitive matching (\`ILIKE '%...%'\`) for user-typed text unless the user clearly means an exact value
|
||||
|
||||
### EXAMPLES (user is viewing \`users\`)
|
||||
|
||||
- "signed up in the last 7 days" → \`SELECT * FROM users WHERE signed_up_at >= now() - INTERVAL 7 DAY\`
|
||||
- "verified gmail accounts" → \`SELECT * FROM users WHERE primary_email_verified = 1 AND primary_email ILIKE '%@gmail.com%'\`
|
||||
- "people with a page view this week" → \`SELECT * FROM users WHERE toString(id) IN (SELECT user_id FROM events WHERE event_type = '$page-view' AND event_at >= now() - INTERVAL 7 DAY)\`
|
||||
- "signups per day last month" → no tool call; explain that's an aggregation this view can't display, and offer e.g. "show signups from last month" as a filter instead.
|
||||
`,
|
||||
|
||||
"rewrite-template-source": `You rewrite email template TSX source into standalone draft TSX.
|
||||
|
||||
@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
EyeIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
SparkleIcon,
|
||||
SpinnerGapIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useCallback, useState, type KeyboardEvent } from "react";
|
||||
import type { AiQueryChat } from "./use-ai-query-chat";
|
||||
|
||||
type AiQueryBarProps = {
|
||||
chat: AiQueryChat,
|
||||
/** Whether the AI has committed a query (drives the purple accent). */
|
||||
isActive: boolean,
|
||||
/** Invoked when the user clicks the eye button. */
|
||||
onOpenDialog: () => void,
|
||||
/** Invoked when the user clicks the reset (clear) button. */
|
||||
onReset: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* AI-powered search bar for the analytics tables page. Drops into the
|
||||
* DataGridToolbar in place of the built-in quick-search input. Typing
|
||||
* a message and pressing Enter sends a prompt to the shared AI chat;
|
||||
* the AI responds by calling the `queryAnalytics` tool, and the
|
||||
* extracted query drives the grid (via the parent component).
|
||||
*/
|
||||
export function AiQueryBar({
|
||||
chat,
|
||||
isActive,
|
||||
onOpenDialog,
|
||||
onReset,
|
||||
}: AiQueryBarProps) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const text = input.trim();
|
||||
if (!text || chat.isResponding) return;
|
||||
setInput("");
|
||||
runAsynchronously(chat.sendMessage({ text }));
|
||||
}, [input, chat]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-8 min-w-0 flex-1 items-center gap-2 rounded-xl px-2.5 sm:max-w-72",
|
||||
"border border-black/[0.08] dark:border-white/[0.06]",
|
||||
"bg-white dark:bg-background shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06] transition-shadow hover:transition-none",
|
||||
isActive
|
||||
? "ring-purple-500/30 focus-within:ring-purple-500/50 dark:ring-purple-400/30 dark:focus-within:ring-purple-400/50"
|
||||
: "hover:ring-black/[0.12] dark:hover:ring-white/[0.1] focus-within:ring-foreground/[0.18]",
|
||||
)}
|
||||
>
|
||||
<SparkleIcon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isActive ? "text-purple-400" : "text-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isActive ? "Refine with AI…" : "Ask about your analytics…"
|
||||
}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 bg-transparent text-xs outline-none",
|
||||
"placeholder:text-muted-foreground/40",
|
||||
)}
|
||||
disabled={chat.isResponding}
|
||||
/>
|
||||
{chat.isResponding && (
|
||||
<SpinnerGapIcon className="h-3 w-3 shrink-0 animate-spin text-purple-400" />
|
||||
)}
|
||||
{!chat.isResponding && input.trim().length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-purple-400 transition-colors hover:transition-none"
|
||||
aria-label="Send"
|
||||
>
|
||||
<PaperPlaneTiltIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<SimpleTooltip tooltip="Open AI query builder">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDialog}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-0.5 transition-colors hover:transition-none",
|
||||
isActive
|
||||
? "text-purple-400 hover:text-purple-300"
|
||||
: "text-muted-foreground/60 hover:text-foreground",
|
||||
)}
|
||||
aria-label="Open AI query builder"
|
||||
>
|
||||
<EyeIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<SimpleTooltip tooltip="Clear AI query">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
aria-label="Clear AI query"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,483 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import type { CmdKPreviewProps } from "@/components/cmdk-commands";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview";
|
||||
import { useUpdateConfig } from "@/components/config-update";
|
||||
import { AssistantRuntimeProvider, type ToolCallContentPartProps } from "@assistant-ui/react";
|
||||
import {
|
||||
ArrowClockwiseIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FloppyDiskIcon,
|
||||
LayoutIcon,
|
||||
SparkleIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto";
|
||||
import {
|
||||
runAsynchronously,
|
||||
runAsynchronouslyWithAlert,
|
||||
} from "@hexclave/shared/dist/utils/promises";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
import type { AiQueryChat } from "./use-ai-query-chat";
|
||||
|
||||
function AnalyticsQueryToolCall(
|
||||
props: ToolCallContentPartProps & {
|
||||
currentQuery: string | null,
|
||||
onApplyQuery: (query: string) => void,
|
||||
},
|
||||
) {
|
||||
const query = (props.args as { query?: unknown } | undefined)?.query;
|
||||
const queryString = typeof query === "string" ? query : "";
|
||||
const result = props.result as { success?: unknown } | null | undefined;
|
||||
const isSuccessful = props.status.type === "complete" && result?.success !== false;
|
||||
const canApply = isSuccessful && queryString.trim().length > 0 && queryString !== props.currentQuery;
|
||||
|
||||
return (
|
||||
<ToolFallback
|
||||
{...props}
|
||||
headerAction={canApply ? (
|
||||
<span className="flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<SimpleTooltip tooltip="Use this query">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onApplyQuery(queryString)}
|
||||
className="inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-[10px] font-medium leading-none text-muted-foreground/70 transition-colors hover:transition-none hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
aria-label="Use this query"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-2.5 w-2.5" />
|
||||
Use query
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AiQueryWelcome() {
|
||||
return (
|
||||
<div className="flex w-full max-w-[var(--thread-max-width)] flex-grow flex-col">
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center py-16 px-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center mb-4 ring-1 ring-purple-500/20">
|
||||
<SparkleIcon className="w-6 h-6 text-purple-400" weight="duotone" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold tracking-tight text-foreground mb-1.5">
|
||||
Build an analytics query
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground text-center max-w-[300px] leading-relaxed">
|
||||
Ask for a table, segment, trend, or funnel. Try “daily signups over the last 30 days” or “top 10 users by event count this week”.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Save query sub-dialog ──────────────────────────────────────────
|
||||
|
||||
function SaveQueryInlineDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sqlQuery,
|
||||
}: {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
sqlQuery: string,
|
||||
}) {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDisplayName("");
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!displayName.trim() || !sqlQuery.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Reuse an existing folder if available, otherwise create an
|
||||
// "AI Queries" bucket on the fly so the save flow never stalls
|
||||
// on folder management.
|
||||
const existingFolders = Object.entries(config.analytics.queryFolders);
|
||||
let folderId: string;
|
||||
if (existingFolders.length > 0) {
|
||||
folderId = existingFolders[0]![0];
|
||||
} else {
|
||||
folderId = generateSecureRandomString();
|
||||
await updateConfig({
|
||||
adminApp,
|
||||
configUpdate: {
|
||||
[`analytics.queryFolders.${folderId}`]: {
|
||||
displayName: "AI Queries",
|
||||
sortOrder: 0,
|
||||
queries: {},
|
||||
},
|
||||
},
|
||||
pushable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const queryId = generateSecureRandomString();
|
||||
await updateConfig({
|
||||
adminApp,
|
||||
configUpdate: {
|
||||
[`analytics.queryFolders.${folderId}.queries.${queryId}`]: {
|
||||
displayName: displayName.trim(),
|
||||
sqlQuery,
|
||||
},
|
||||
},
|
||||
pushable: false,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [displayName, sqlQuery, config, updateConfig, adminApp, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save query</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ai-save-query-name">Name</Label>
|
||||
<Input
|
||||
id="ai-save-query-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Recent signups"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runAsynchronouslyWithAlert(handleSave);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/50 bg-muted/30 p-2">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground max-h-32 overflow-auto">
|
||||
{sqlQuery}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => runAsynchronouslyWithAlert(handleSave)}
|
||||
disabled={!displayName.trim() || saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Build dashboard sub-dialog ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reuses the existing `CreateDashboardPreview` component (the same
|
||||
* one the Cmd+K command center uses) so the dashboard-builder
|
||||
* experience is identical whether you enter it from the command
|
||||
* palette or from the analytics AI query builder. Most of
|
||||
* `CmdKPreviewProps` are unused by `CreateDashboardPreview` internally,
|
||||
* so we pass no-op stubs for them.
|
||||
*/
|
||||
function BuildDashboardDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sqlQuery,
|
||||
}: {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
sqlQuery: string,
|
||||
}) {
|
||||
// Synthesize a prompt that pre-seeds the SQL query as context so
|
||||
// the dashboard the AI generates visualizes exactly these results.
|
||||
const dashboardPrompt = useMemo(
|
||||
() =>
|
||||
`Build a dashboard that visualizes the results of this ClickHouse query:\n\n\`\`\`sql\n${sqlQuery}\n\`\`\``,
|
||||
[sqlQuery],
|
||||
);
|
||||
|
||||
const stubProps: Omit<CmdKPreviewProps, "query" | "onClose"> = {
|
||||
isSelected: true,
|
||||
registerOnFocus: () => {
|
||||
// no-op
|
||||
},
|
||||
unregisterOnFocus: () => {
|
||||
// no-op
|
||||
},
|
||||
onBlur: () => {
|
||||
// no-op
|
||||
},
|
||||
registerNestedCommands: () => {
|
||||
// no-op
|
||||
},
|
||||
navigateToNested: () => {
|
||||
// no-op
|
||||
},
|
||||
depth: 0,
|
||||
pathname: "",
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl h-[80vh] p-0 overflow-hidden flex flex-col">
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<LayoutIcon className="h-4 w-4 text-cyan-500" />
|
||||
Build dashboard
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0">
|
||||
{open && (
|
||||
<CreateDashboardPreview
|
||||
query={dashboardPrompt}
|
||||
onClose={() => onOpenChange(false)}
|
||||
{...stubProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main dialog ────────────────────────────────────────────────────
|
||||
|
||||
type AiQueryDialogProps = {
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
chat: AiQueryChat,
|
||||
/** The query currently driving the data grid (may be `null` if none yet). */
|
||||
currentQuery: string | null,
|
||||
};
|
||||
|
||||
export function AiQueryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chat,
|
||||
currentQuery,
|
||||
}: AiQueryDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [currentQueryDraft, setCurrentQueryDraft] = useState(currentQuery ?? "");
|
||||
const assistantContentComponents = useMemo(() => ({
|
||||
Text: MarkdownText,
|
||||
tools: {
|
||||
Fallback: ToolFallback,
|
||||
by_name: {
|
||||
queryAnalytics: (props: ToolCallContentPartProps) => (
|
||||
<AnalyticsQueryToolCall
|
||||
{...props}
|
||||
currentQuery={currentQuery}
|
||||
onApplyQuery={chat.rewindToQuery}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
}), [chat.rewindToQuery, currentQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQueryDraft(currentQuery ?? "");
|
||||
}, [currentQuery]);
|
||||
|
||||
const applyCurrentQueryDraft = useCallback(() => {
|
||||
if (currentQueryDraft.trim().length === 0 || currentQueryDraft === currentQuery) return;
|
||||
chat.rewindToQuery(currentQueryDraft);
|
||||
}, [chat, currentQuery, currentQueryDraft]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!currentQueryDraft) return;
|
||||
await navigator.clipboard.writeText(currentQueryDraft);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [currentQueryDraft]);
|
||||
|
||||
const canActOnQuery = currentQueryDraft.trim().length > 0;
|
||||
const hasUnappliedCurrentQueryEdits = currentQueryDraft.trim().length > 0 && currentQueryDraft !== (currentQuery ?? "");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent noCloseButton className="max-w-3xl h-[80vh] p-0 overflow-hidden flex flex-col gap-0">
|
||||
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<SparkleIcon className="h-4 w-4 text-purple-400" />
|
||||
AI query builder
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
{chat.messages.length > 0 && (
|
||||
<SimpleTooltip tooltip="Clear chat">
|
||||
<button
|
||||
type="button"
|
||||
onClick={chat.clearMessages}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="shrink-0 border-b border-black/[0.06] bg-zinc-50/80 dark:border-border/40 dark:bg-muted/20">
|
||||
<div className="flex items-center justify-between px-5 py-2">
|
||||
<Label className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Current query
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasUnappliedCurrentQueryEdits && (
|
||||
<SimpleTooltip tooltip="Apply query">
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyCurrentQueryDraft}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
{canActOnQuery && (
|
||||
<SimpleTooltip tooltip={copied ? "Copied!" : "Copy SQL"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAsynchronously(handleCopy())}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] transition-colors hover:transition-none"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-3">
|
||||
<Textarea
|
||||
value={currentQueryDraft}
|
||||
onChange={(e) => setCurrentQueryDraft(e.target.value)}
|
||||
onBlur={applyCurrentQueryDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
applyCurrentQueryDraft();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask the AI a question to generate a query."
|
||||
className="min-h-16 max-h-32 resize-y overflow-auto border-black/[0.08] bg-white/95 font-mono text-[11px] text-foreground/90 shadow-sm ring-1 ring-black/[0.06] placeholder:italic placeholder:text-muted-foreground dark:border-border/40 dark:bg-background/60 dark:ring-white/[0.06]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chat.error && (
|
||||
<div className="mx-5 mt-3 shrink-0 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-[12px] text-red-300">
|
||||
{chat.error.message || "Failed to get a response."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<AssistantRuntimeProvider runtime={chat.runtime}>
|
||||
<Thread
|
||||
composerPlaceholder="Refine the query..."
|
||||
runningStatusMessages={["Thinking..."]}
|
||||
assistantContentComponents={assistantContentComponents}
|
||||
welcome={<AiQueryWelcome />}
|
||||
/>
|
||||
</AssistantRuntimeProvider>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t border-border/40 sm:justify-between gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Save the query or turn it into a live dashboard.
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canActOnQuery}
|
||||
onClick={() => setSaveOpen(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<FloppyDiskIcon className="h-3.5 w-3.5" />
|
||||
Save query
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canActOnQuery}
|
||||
onClick={() => setBuildOpen(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<LayoutIcon className="h-3.5 w-3.5" />
|
||||
Build dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SaveQueryInlineDialog
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
sqlQuery={currentQueryDraft}
|
||||
/>
|
||||
<BuildDashboardDialog
|
||||
open={buildOpen}
|
||||
onOpenChange={setBuildOpen}
|
||||
sqlQuery={currentQueryDraft}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Link } from "@/components/link";
|
||||
import { DesignSelectorDropdown } from "@/components/design-components/select";
|
||||
import { Button, Typography } from "@/components/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowClockwiseIcon, CodeIcon } from "@phosphor-icons/react";
|
||||
@ -9,13 +10,12 @@ import { useCallback, useState } from "react";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { AnalyticsEventLimitBanner } from "../shared";
|
||||
import { AiQueryBar } from "./ai-query-bar";
|
||||
import { AiQueryDialog } from "./ai-query-dialog";
|
||||
import {
|
||||
QueryDataGrid,
|
||||
type QueryDataGridMode,
|
||||
} from "./query-data-grid";
|
||||
import { useAiQueryChat } from "./use-ai-query-chat";
|
||||
import { TableSearchBar } from "./table-search-bar";
|
||||
import { useAiTableFilterChat } from "./use-ai-table-filter-chat";
|
||||
|
||||
// ─── Available tables ───────────────────────────────────────────────
|
||||
|
||||
@ -27,8 +27,6 @@ type TableConfig = {
|
||||
};
|
||||
|
||||
type TableId = string;
|
||||
const ANALYTICS_TABLES_STICKY_TOP = "var(--analytics-tables-sticky-top)";
|
||||
const ANALYTICS_TABLES_SIDEBAR_HEIGHT = "var(--analytics-tables-sidebar-height)";
|
||||
|
||||
const AVAILABLE_TABLES = new Map<TableId, TableConfig>([
|
||||
[
|
||||
@ -141,84 +139,76 @@ const AVAILABLE_TABLES = new Map<TableId, TableConfig>([
|
||||
],
|
||||
]);
|
||||
|
||||
const AVAILABLE_TABLE_OPTIONS = [...AVAILABLE_TABLES.entries()].map(
|
||||
([value, config]) => ({ value, label: config.displayName }),
|
||||
);
|
||||
|
||||
// ─── Per-table content ──────────────────────────────────────────────
|
||||
|
||||
function TableContent({ tableId }: { tableId: TableId }) {
|
||||
const tableConfig = AVAILABLE_TABLES.get(tableId) ?? throwErr(`Unknown analytics table: ${tableId}`);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// Shared AI chat state — feeds both the search bar and the eye
|
||||
// dialog, so they operate on a single conversation thread.
|
||||
const chat = useAiQueryChat();
|
||||
// AI thread behind the search bar — constrained to row filters over this
|
||||
// table, so the grid's columns never change.
|
||||
const filterChat = useAiTableFilterChat(tableId);
|
||||
|
||||
const aiQuery = chat.latestQuery;
|
||||
const isAiActive = aiQuery != null;
|
||||
const filterQuery = filterChat.latestQuery;
|
||||
|
||||
// When the AI has committed a query, it becomes the source of
|
||||
// truth; otherwise fall back to the table's own default query.
|
||||
const effectiveQuery = aiQuery ?? tableConfig.baseQuery;
|
||||
const effectiveMode: QueryDataGridMode = isAiActive ? "one-shot" : "paginated";
|
||||
|
||||
// Default sort / search only apply while the AI is inactive — an
|
||||
// AI-generated aggregate won't have an `event_at` column to sort on.
|
||||
const defaultOrderBy = isAiActive ? undefined : tableConfig.defaultOrderBy;
|
||||
const defaultOrderDir = isAiActive ? undefined : tableConfig.defaultOrderDir;
|
||||
|
||||
const handleResetChat = useCallback(() => {
|
||||
chat.clearMessages();
|
||||
}, [chat]);
|
||||
|
||||
const aiSearchBar = (
|
||||
<AiQueryBar
|
||||
chat={chat}
|
||||
isActive={isAiActive}
|
||||
onOpenDialog={() => setDialogOpen(true)}
|
||||
onReset={handleResetChat}
|
||||
/>
|
||||
);
|
||||
const effectiveQuery = filterQuery ?? tableConfig.baseQuery;
|
||||
const effectiveMode: QueryDataGridMode = filterQuery != null ? "one-shot" : "paginated";
|
||||
|
||||
const renderToolbarExtra = useCallback(
|
||||
(ctx: { rowCount: number, hasMore: boolean, reload: () => void }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={ctx.reload}
|
||||
className="h-7 w-7"
|
||||
title="Refresh"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="hidden sm:inline px-1 text-[11px] tabular-nums text-muted-foreground">
|
||||
{ctx.hasMore
|
||||
? `${ctx.rowCount.toLocaleString()}+ rows`
|
||||
: `${ctx.rowCount.toLocaleString()} rows`}
|
||||
</span>
|
||||
</div>
|
||||
(ctx: { rowCount: number, hasMore: boolean }) => (
|
||||
<span className="hidden h-[22px] shrink-0 items-center rounded-full bg-foreground/[0.04] px-2 text-[10px] tabular-nums text-muted-foreground ring-1 ring-foreground/[0.06] sm:inline-flex">
|
||||
{ctx.hasMore
|
||||
? `${ctx.rowCount.toLocaleString()}+ rows`
|
||||
: `${ctx.rowCount.toLocaleString()} rows`}
|
||||
</span>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const renderToolbarActions = useCallback(
|
||||
(ctx: { reload: () => void }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={ctx.reload}
|
||||
className="h-7 gap-1.5 px-2.5 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
title="Refresh"
|
||||
>
|
||||
<ArrowClockwiseIcon className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
// fillHeight grid owns its own scrollport below the page header, so rows never
|
||||
// paint under the sticky translucent header (the dark-mode seam bug).
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<QueryDataGrid
|
||||
query={effectiveQuery}
|
||||
mode={effectiveMode}
|
||||
defaultOrderBy={defaultOrderBy}
|
||||
defaultOrderDir={defaultOrderDir}
|
||||
enableQuickSearchFilter={!isAiActive}
|
||||
searchBar={aiSearchBar}
|
||||
defaultOrderBy={tableConfig.defaultOrderBy}
|
||||
defaultOrderDir={tableConfig.defaultOrderDir}
|
||||
searchBar={(ctx) => (
|
||||
<TableSearchBar
|
||||
ctx={ctx}
|
||||
queryKey={effectiveQuery}
|
||||
chat={filterChat}
|
||||
activeFilterQuery={filterQuery}
|
||||
filterRejected={filterChat.filterRejected}
|
||||
onAiSubmit={(text) => filterChat.sendMessage({ text })}
|
||||
onClearFilter={filterChat.clearMessages}
|
||||
/>
|
||||
)}
|
||||
toolbarExtra={renderToolbarExtra}
|
||||
toolbarActions={renderToolbarActions}
|
||||
exportFilename={`${tableId}-export`}
|
||||
fillHeight={false}
|
||||
stickyTop={ANALYTICS_TABLES_STICKY_TOP}
|
||||
/>
|
||||
|
||||
<AiQueryDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
chat={chat}
|
||||
currentQuery={aiQuery}
|
||||
fillHeight
|
||||
stickyTop={0}
|
||||
horizontalScrollbarPosition="top"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -229,70 +219,98 @@ export default function PageClient() {
|
||||
|
||||
return (
|
||||
<AppEnabledGuard appId="analytics">
|
||||
<PageLayout fillWidth noPadding>
|
||||
<AnalyticsEventLimitBanner />
|
||||
<div className="flex w-full min-w-0 items-stretch lg:-ml-2 [--analytics-tables-sticky-top:3.5rem] [--analytics-tables-sidebar-height:calc(100vh-3.5rem)] dark:[--analytics-tables-sticky-top:5rem] dark:[--analytics-tables-sidebar-height:calc(100vh-6rem)]">
|
||||
{/* Left sidebar — hidden on mobile */}
|
||||
<div
|
||||
className="hidden lg:flex w-48 min-h-0 flex-shrink-0 self-start flex-col overflow-hidden border-r border-border/50 pl-2 sticky"
|
||||
style={{
|
||||
top: ANALYTICS_TABLES_STICKY_TOP,
|
||||
height: ANALYTICS_TABLES_SIDEBAR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
<Typography className="px-3 mb-3 text-xs font-semibold uppercase tracking-wide text-foreground/70">
|
||||
Tables
|
||||
</Typography>
|
||||
<div className="space-y-1">
|
||||
{[...AVAILABLE_TABLES.entries()].map(([id, config]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSelectedTable(id)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors hover:transition-none",
|
||||
selectedTable === id
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{config.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="./queries"
|
||||
className="mt-4 flex items-center gap-2 border-t border-border/50 px-3 pt-4 py-2 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors hover:transition-none w-full"
|
||||
>
|
||||
<CodeIcon className="h-4 w-4" />
|
||||
Queries
|
||||
</Link>
|
||||
</div>
|
||||
{/* containedHeight: page fills the shell under the header and scrolls internally,
|
||||
so table rows cannot travel behind the floating dark-mode header. */}
|
||||
<PageLayout fillWidth noPadding containedHeight>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0">
|
||||
<AnalyticsEventLimitBanner />
|
||||
</div>
|
||||
|
||||
{/* Right content — flush to card edge; companion gap is on <main> in sidebar-layout */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col",
|
||||
"[&_[role=grid]]:rounded-r-none",
|
||||
"[&_[role=grid]_.sticky]:rounded-t-none",
|
||||
// Toolbar row only (first child of sticky chrome) — analytics layout
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pt-3",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pb-2.5",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pr-0",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pl-2.5",
|
||||
)}
|
||||
>
|
||||
{selectedTable ? (
|
||||
<TableContent key={selectedTable} tableId={selectedTable} />
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Typography variant="secondary">
|
||||
Select a table to view its contents
|
||||
</Typography>
|
||||
<div className="shrink-0 px-3 py-2 lg:hidden">
|
||||
<label htmlFor="analytics-table-selector" className="sr-only">
|
||||
Table
|
||||
</label>
|
||||
<DesignSelectorDropdown
|
||||
value={selectedTable ?? "events"}
|
||||
onValueChange={setSelectedTable}
|
||||
options={AVAILABLE_TABLE_OPTIONS}
|
||||
placeholder="Select a table"
|
||||
size="sm"
|
||||
triggerId="analytics-table-selector"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dark: match the primary nav's rounded-2xl so the gap junction mirrors
|
||||
the same radius on both sides (nav top-right ↔ tables top-left).
|
||||
Light: only round the left edge — the shell card already owns the
|
||||
top-right radius, so an inner tr curve reads as a stray notch. */}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden rounded-l-2xl dark:rounded-tr-2xl lg:ml-0.5">
|
||||
{/* Use the same surface treatment as the primary sidebar so equal radii render
|
||||
identically. Omit the right border to keep the sidebar/grid junction divider-free. */}
|
||||
<div className="hidden w-48 min-h-0 flex-shrink-0 flex-col overflow-hidden rounded-l-2xl bg-black/[0.03] dark:border dark:border-r-0 dark:border-foreground/5 dark:bg-foreground/5 dark:backdrop-blur-2xl dark:shadow-sm lg:flex">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pl-2">
|
||||
<div className="min-h-full px-4 py-4">
|
||||
<Typography className="px-3 mb-3 text-xs font-semibold uppercase tracking-wide text-foreground/70">
|
||||
Tables
|
||||
</Typography>
|
||||
<div className="space-y-1">
|
||||
{[...AVAILABLE_TABLES.entries()].map(([id, config]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSelectedTable(id)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors hover:transition-none",
|
||||
selectedTable === id
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{config.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="./queries"
|
||||
className="mt-4 flex items-center gap-2 border-t border-border/50 px-3 pt-4 py-2 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors hover:transition-none w-full"
|
||||
>
|
||||
<CodeIcon className="h-4 w-4" />
|
||||
Queries
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right content — grid fills remaining height and scrolls internally.
|
||||
Sticky chrome is its own composited layer and paints square over a
|
||||
parent's overflow:hidden radius, so dark mode clips the panel itself.
|
||||
Below lg the tables sidebar is hidden and the grid owns both top
|
||||
corners; at lg+ the sidebar owns the left radius. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
"[&_[role=grid]]:rounded-none",
|
||||
"[&_[role=grid]_.sticky]:rounded-none",
|
||||
// Toolbar row only (first child of sticky chrome) — analytics layout
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pt-3",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pb-2.5",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pr-3",
|
||||
"[&_[role=grid]_.sticky>div:first-child>div]:pl-2.5",
|
||||
"max-lg:dark:rounded-t-2xl max-lg:dark:[clip-path:inset(0_round_1rem_1rem_0_0)]",
|
||||
"lg:dark:rounded-tr-2xl lg:dark:[clip-path:inset(0_round_0_1rem_0_0)]",
|
||||
)}
|
||||
>
|
||||
{selectedTable ? (
|
||||
<TableContent key={selectedTable} tableId={selectedTable} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Typography variant="secondary">
|
||||
Select a table to view its contents
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
@ -108,13 +108,21 @@ export type QueryDataGridProps = {
|
||||
| ReactNode
|
||||
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
|
||||
/**
|
||||
* Extra content slotted into the default toolbar, to the LEFT of
|
||||
* the built-in columns / export actions. Can be a node or a function
|
||||
* receiving the extended context.
|
||||
* Extra content slotted into the default toolbar's leading cluster
|
||||
* (after search), e.g. filters or a row-count badge. Can be a node or
|
||||
* a function receiving the extended context.
|
||||
*/
|
||||
toolbarExtra?:
|
||||
| ReactNode
|
||||
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
|
||||
/**
|
||||
* Extra content in the trailing action cluster, immediately before
|
||||
* Columns / Export (e.g. a labeled Refresh). Can be a node or a
|
||||
* function receiving the extended context.
|
||||
*/
|
||||
toolbarActions?:
|
||||
| ReactNode
|
||||
| ((ctx: QueryDataGridToolbarContext<RowData>) => ReactNode),
|
||||
/** Whether the default row-click-to-inspect dialog is enabled. Defaults to `true`. */
|
||||
enableRowDetailDialog?: boolean,
|
||||
/** Custom row click handler. Overrides the default row detail dialog. */
|
||||
@ -133,6 +141,8 @@ export type QueryDataGridProps = {
|
||||
fillHeight?: boolean,
|
||||
/** Sticky top offset forwarded to the underlying DataGrid. */
|
||||
stickyTop?: number | string,
|
||||
/** Where the horizontal scrollbar is shown when columns overflow. */
|
||||
horizontalScrollbarPosition?: "top" | "bottom",
|
||||
};
|
||||
|
||||
export type QueryDataGridHandle = {
|
||||
@ -320,6 +330,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
toolbar,
|
||||
searchBar,
|
||||
toolbarExtra,
|
||||
toolbarActions,
|
||||
enableRowDetailDialog = true,
|
||||
onRowClick,
|
||||
onError,
|
||||
@ -329,6 +340,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
footer = false,
|
||||
fillHeight = true,
|
||||
stickyTop,
|
||||
horizontalScrollbarPosition,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@ -343,10 +355,6 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
// read the latest column list without being re-created every time
|
||||
// the schema updates.
|
||||
const discoveredColumnsRef = useRef<string[]>([]);
|
||||
const queryRef = useRef(query);
|
||||
queryRef.current = query;
|
||||
const modeRef = useRef(mode);
|
||||
modeRef.current = mode;
|
||||
const enableSearchFilterRef = useRef(enableQuickSearchFilter);
|
||||
enableSearchFilterRef.current = enableQuickSearchFilter;
|
||||
const defaultOrderByRef = useRef(defaultOrderBy);
|
||||
@ -370,12 +378,12 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
};
|
||||
});
|
||||
|
||||
// Whenever the query changes we want fresh columns + a reset sort
|
||||
// so a leftover sort from a previous schema doesn't crash the
|
||||
// next query (nonexistent column in ORDER BY).
|
||||
// Whenever the query changes, reset sort / search / page so a leftover
|
||||
// ORDER BY from a previous schema can't crash the next fetch. Keep the
|
||||
// last discovered columns — clearing them would leave the grid with an
|
||||
// empty schema during the reload (blank pane instead of a shimmer), and
|
||||
// the next successful page overwrites them anyway.
|
||||
useEffect(() => {
|
||||
setDiscoveredColumns([]);
|
||||
discoveredColumnsRef.current = [];
|
||||
setError(null);
|
||||
setGridState((prev) => ({
|
||||
...prev,
|
||||
@ -430,6 +438,9 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
[discoveredColumns],
|
||||
);
|
||||
|
||||
// Capture `query` and `mode` so the generator identity and the SQL it
|
||||
// executes change together. useDataSource keys off that identity to
|
||||
// refetch when either input changes.
|
||||
const dataSource = useMemo<DataGridDataSource<RowData>>(() => {
|
||||
return async function* (params) {
|
||||
setError(null);
|
||||
@ -452,8 +463,8 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
const applyFilter = enableSearchFilterRef.current;
|
||||
|
||||
const finalQuery = buildFinalQuery({
|
||||
baseQuery: queryRef.current,
|
||||
mode: modeRef.current,
|
||||
baseQuery: query,
|
||||
mode,
|
||||
orderBy,
|
||||
orderDir,
|
||||
search: applyFilter ? search : "",
|
||||
@ -495,7 +506,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
yield { rows: [], hasMore: false };
|
||||
}
|
||||
};
|
||||
}, [serverApp]);
|
||||
}, [serverApp, query, mode]);
|
||||
|
||||
const getRowId = useCallback((row: RowData): string => {
|
||||
if (typeof row[INTERNAL_ROW_ID_KEY] === "string") return row[INTERNAL_ROW_ID_KEY];
|
||||
@ -562,11 +573,10 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
*
|
||||
* Priority:
|
||||
* 1. `toolbar` (full override) — caller owns the whole row
|
||||
* 2. `searchBar` provided — render our own DataGridToolbar
|
||||
* wrapper that hides the built-in quick search and slots the
|
||||
* caller's node where it used to live; keeps Columns/Export
|
||||
* intact. `toolbarExtra` (if provided) is passed through as
|
||||
* the built-in extras slot.
|
||||
* 2. `searchBar` or `toolbarActions` provided — render our own
|
||||
* DataGridToolbar wrapper. The built-in quick search is hidden only
|
||||
* when `searchBar` replaces it; Columns/Export stay intact and the
|
||||
* leading/trailing extension slots are passed through.
|
||||
* 3. neither — undefined, so the DataGrid
|
||||
* falls back to its default toolbar behaviour (built-in
|
||||
* quick search, extras, columns, export).
|
||||
@ -582,16 +592,23 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
: typeof toolbarExtra === "function"
|
||||
? toolbarExtra(extended)
|
||||
: toolbarExtra;
|
||||
const actions =
|
||||
toolbarActions === undefined
|
||||
? undefined
|
||||
: typeof toolbarActions === "function"
|
||||
? toolbarActions(extended)
|
||||
: toolbarActions;
|
||||
return (
|
||||
<DataGridToolbar
|
||||
ctx={ctx}
|
||||
extra={extras}
|
||||
extraLeading={leading}
|
||||
hideQuickSearch
|
||||
extraActions={actions}
|
||||
hideQuickSearch={searchBar !== undefined}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[searchBar, toolbarExtra, extendCtx],
|
||||
[searchBar, toolbarExtra, toolbarActions, extendCtx],
|
||||
);
|
||||
|
||||
const renderForwardedToolbar = useCallback(
|
||||
@ -604,7 +621,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
|
||||
const resolvedToolbar = toolbar
|
||||
? renderForwardedToolbar
|
||||
: searchBar !== undefined
|
||||
: searchBar !== undefined || toolbarActions !== undefined
|
||||
? renderCustomToolbar
|
||||
: undefined;
|
||||
|
||||
@ -612,11 +629,11 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
// When we've already built a custom toolbar above for the
|
||||
// `searchBar` case, the `toolbarExtra` prop is consumed inside
|
||||
// that custom toolbar — don't also pass it to DataGrid.
|
||||
if (toolbar || searchBar !== undefined) return undefined;
|
||||
if (toolbar || searchBar !== undefined || toolbarActions !== undefined) return undefined;
|
||||
if (toolbarExtra === undefined) return undefined;
|
||||
if (typeof toolbarExtra !== "function") return toolbarExtra;
|
||||
return (ctx: DataGridToolbarContext<RowData>) => toolbarExtra(extendCtx(ctx));
|
||||
}, [toolbar, searchBar, toolbarExtra, extendCtx]);
|
||||
}, [toolbar, searchBar, toolbarActions, toolbarExtra, extendCtx]);
|
||||
|
||||
return (
|
||||
<div className={fillHeight ? "flex flex-1 min-h-0 flex-col" : "flex flex-col"}>
|
||||
@ -654,6 +671,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
selectionMode="none"
|
||||
fillHeight={fillHeight}
|
||||
stickyTop={stickyTop}
|
||||
horizontalScrollbarPosition={horizontalScrollbarPosition}
|
||||
toolbar={resolvedToolbar}
|
||||
toolbarExtra={resolvedToolbarExtra}
|
||||
footer={footer ? undefined : false}
|
||||
@ -662,7 +680,11 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
|
||||
emptyState={
|
||||
emptyState ?? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-16">
|
||||
<Typography variant="secondary">No data available</Typography>
|
||||
<Typography variant="secondary">
|
||||
{gridState.quickSearch.trim().length > 0
|
||||
? "No rows match your search"
|
||||
: "No data available"}
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getValidatedTableFilterQuery,
|
||||
looksLikeNaturalLanguageQuery,
|
||||
} from "./search-bar-logic";
|
||||
|
||||
describe("looksLikeNaturalLanguageQuery", () => {
|
||||
it.each([
|
||||
// plain substring searches stay plain
|
||||
["", false],
|
||||
[" ", false],
|
||||
["alice", false],
|
||||
["alice@example.com", false],
|
||||
["no-reply@example.com", false], // single token; "no" cue must not trigger
|
||||
["4c5ba425-6c22-4f65-9e33-3e2b1c5a8d1f", false],
|
||||
["/dashboard/settings", false],
|
||||
["utm_source=google", false], // bare "=" appears in real data values
|
||||
["john smith", false],
|
||||
["page view", false],
|
||||
// natural-language conditions go to the AI
|
||||
["users who signed up last week", true],
|
||||
["verified users", true],
|
||||
["more than 5 events", true],
|
||||
["signed up before january", true],
|
||||
["emails that bounced", true],
|
||||
["count > 10", true],
|
||||
["how many users are there?", true],
|
||||
["anything at all?", true], // trailing question mark is always a question
|
||||
["users without a display name", true],
|
||||
["events from the past 3 days", true],
|
||||
])("%j → %s", (input, expected) => {
|
||||
expect(looksLikeNaturalLanguageQuery(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValidatedTableFilterQuery", () => {
|
||||
it("accepts a bare SELECT * on the table", () => {
|
||||
expect(getValidatedTableFilterQuery("SELECT * FROM users", "users")).toBe(
|
||||
"SELECT * FROM users",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts WHERE filters, default. prefixes, backticks, and mixed case", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"select * from default.users where primary_email_verified = 1",
|
||||
"users",
|
||||
),
|
||||
).toBe("select * from default.users where primary_email_verified = 1");
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM `users` WHERE signed_up_at >= now() - INTERVAL 7 DAY",
|
||||
"users",
|
||||
),
|
||||
).toContain("WHERE signed_up_at");
|
||||
});
|
||||
|
||||
it("accepts multi-line queries and strips trailing semicolons", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT *\nFROM users\nWHERE is_anonymous = 0;",
|
||||
"users",
|
||||
),
|
||||
).toBe("SELECT *\nFROM users\nWHERE is_anonymous = 0");
|
||||
});
|
||||
|
||||
it("accepts subqueries inside the WHERE condition", () => {
|
||||
const query =
|
||||
"SELECT * FROM users WHERE toString(id) IN (SELECT user_id FROM events GROUP BY user_id HAVING count() > 5 LIMIT 100)";
|
||||
expect(getValidatedTableFilterQuery(query, "users")).toBe(query);
|
||||
});
|
||||
|
||||
it("rejects top-level sorting and pagination before or after a row filter", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users ORDER BY signed_up_at DESC",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery("SELECT * FROM users LIMIT 100", "users"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE primary_email_verified = 1 ORDER BY signed_up_at DESC",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users PREWHERE primary_email_verified = 1 LIMIT 100",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows result-shaping clauses inside row-filter subqueries and strings", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE toString(id) IN (SELECT user_id FROM events ORDER BY event_at DESC LIMIT 100)",
|
||||
"users",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users PREWHERE primary_email ILIKE '%order by recent%'",
|
||||
"users",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("allows format() calls while rejecting a top-level FORMAT clause", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE format('{}', primary_email) = 'alice@example.com'",
|
||||
"users",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE format ('{}', primary_email) = 'alice@example.com'",
|
||||
"users",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE primary_email != '' FORMAT JSON",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects top-level set operations", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE 1 UNION SELECT * FROM users",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE 1 EXCEPT SELECT * FROM users",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE 1 INTERSECT SELECT * FROM users",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects internal semicolons without rejecting semicolons in strings", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE 1; DROP TABLE users",
|
||||
"users",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users WHERE primary_email = 'alice;bob@example.com'",
|
||||
"users",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects queries on a different table", () => {
|
||||
expect(getValidatedTableFilterQuery("SELECT * FROM events", "users")).toBe(null);
|
||||
// prefix of another table name must not match
|
||||
expect(getValidatedTableFilterQuery("SELECT * FROM users2 WHERE 1", "users")).toBe(null);
|
||||
expect(getValidatedTableFilterQuery("SELECT * FROM users_archive", "users")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects anything that could change the column set", () => {
|
||||
expect(
|
||||
getValidatedTableFilterQuery("SELECT id, primary_email FROM users", "users"),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getValidatedTableFilterQuery("SELECT count() FROM users", "users"),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users u JOIN events e ON toString(u.id) = e.user_id",
|
||||
"users",
|
||||
),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users, events",
|
||||
"users",
|
||||
),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getValidatedTableFilterQuery(
|
||||
"SELECT * FROM users GROUP BY id",
|
||||
"users",
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects empty and non-SELECT input", () => {
|
||||
expect(getValidatedTableFilterQuery("", "users")).toBe(null);
|
||||
expect(getValidatedTableFilterQuery(";", "users")).toBe(null);
|
||||
expect(getValidatedTableFilterQuery("DROP TABLE users", "users")).toBe(null);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Pure decision logic for the analytics table search bar, kept free of React
|
||||
* so the heuristics can be unit-tested exhaustively (see
|
||||
* search-bar-logic.test.ts).
|
||||
*
|
||||
* The search bar is filter-first: typing applies a plain substring (ILIKE)
|
||||
* filter over the current table. Only input that a substring match cannot
|
||||
* express is routed to the AI, which must respond with a row filter of the
|
||||
* shape `SELECT * FROM <table> WHERE ...` so the grid's columns never change.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Words that signal the user is describing a CONDITION ("users who signed up
|
||||
* last week") rather than typing a substring to match ("alice@example.com").
|
||||
* Deliberately excludes anything likely to appear inside real data values —
|
||||
* e.g. bare "com" or "www" — since false positives would hijack ordinary
|
||||
* searches.
|
||||
*/
|
||||
const NATURAL_LANGUAGE_CUES = new Set([
|
||||
// question / command words
|
||||
"who", "whose", "which", "whom", "what", "when", "how", "why",
|
||||
"show", "find", "list", "give", "display", "filter", "get",
|
||||
// comparison / quantity words
|
||||
"more", "less", "most", "least", "fewer", "greater", "than",
|
||||
"under", "over", "above", "below", "between", "top", "bottom", "count",
|
||||
// temporal words
|
||||
"before", "after", "since", "ago", "during", "past", "last", "first",
|
||||
"latest", "newest", "oldest", "recent", "recently", "earlier",
|
||||
"today", "yesterday", "tomorrow",
|
||||
"day", "days", "week", "weeks", "month", "months", "year", "years",
|
||||
"hour", "hours", "minute", "minutes",
|
||||
// state / negation words
|
||||
"verified", "unverified", "anonymous", "empty", "missing", "null",
|
||||
"never", "ever", "only", "all", "any", "every", "none", "not", "without",
|
||||
// relational / auxiliary words common in questions
|
||||
"with", "have", "has", "had", "are", "is", "was", "were", "that",
|
||||
"contains", "containing", "starts", "ends", "starting", "ending",
|
||||
// domain verbs that read as conditions
|
||||
"signed", "created", "updated", "joined", "expired", "delivered",
|
||||
"opened", "clicked", "bounced", "sent",
|
||||
// plural domain nouns — people describe segments with these ("verified
|
||||
// users"), while substring searches contain concrete values instead
|
||||
"users", "teams", "members", "accounts", "sessions", "invitations",
|
||||
"permissions", "channels", "tokens", "emails", "events",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Heuristic: does this input look like a natural-language request (→ send to
|
||||
* the AI on Enter) rather than a plain substring search (→ keep the live
|
||||
* filter)? The zero-results fallback in the search bar catches misses, so
|
||||
* this errs on the side of NOT flagging: single-token input (emails, IDs,
|
||||
* paths) is never natural language.
|
||||
*/
|
||||
export function looksLikeNaturalLanguageQuery(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
// Comparisons can never be expressed as a substring match. Bare `=` is
|
||||
// deliberately NOT a cue — it shows up in legitimate substring searches
|
||||
// like URL query params ("utm_source=google").
|
||||
if (/[<>]=?|!=/.test(trimmed)) return true;
|
||||
if (trimmed.endsWith("?")) return true;
|
||||
// Single tokens (emails, UUIDs, paths) are always substring searches, even
|
||||
// when their word-fragments happen to hit the cue list ("no-reply@…").
|
||||
if (!/\s/.test(trimmed)) return false;
|
||||
const words = trimmed.toLowerCase().match(/[a-z']+/g) ?? [];
|
||||
const cueCount = words.reduce(
|
||||
(count, word) => count + (NATURAL_LANGUAGE_CUES.has(word) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
return (words.length >= 3 && cueCount >= 1) || cueCount >= 2;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const FORBIDDEN_TOP_LEVEL_FILTER_WORDS = new Set([
|
||||
"except",
|
||||
"format",
|
||||
"having",
|
||||
"intersect",
|
||||
"join",
|
||||
"limit",
|
||||
"offset",
|
||||
"qualify",
|
||||
"sample",
|
||||
"settings",
|
||||
"union",
|
||||
"window",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_TOP_LEVEL_FILTER_PAIRS = new Set([
|
||||
"group by",
|
||||
"into outfile",
|
||||
"order by",
|
||||
"with fill",
|
||||
"with totals",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the words visible at SQL parenthesis depth zero, ignoring quoted
|
||||
* strings/identifiers. This is deliberately narrower than a SQL parser: the
|
||||
* filter contract only needs to distinguish clauses owned by the outer grid
|
||||
* from equivalent clauses inside permitted subqueries.
|
||||
*/
|
||||
type TopLevelSqlWord = {
|
||||
value: string,
|
||||
followedByOpenParen: boolean,
|
||||
};
|
||||
|
||||
function getTopLevelSqlWords(sql: string): TopLevelSqlWord[] | null {
|
||||
const words: TopLevelSqlWord[] = [];
|
||||
let currentWord = "";
|
||||
let depth = 0;
|
||||
let quote: "'" | "\"" | "`" | null = null;
|
||||
|
||||
const flushWord = (nextCharacterIndex: number) => {
|
||||
if (currentWord.length === 0) return;
|
||||
let nextNonWhitespaceIndex = nextCharacterIndex;
|
||||
while (/\s/.test(sql[nextNonWhitespaceIndex] ?? "")) {
|
||||
nextNonWhitespaceIndex += 1;
|
||||
}
|
||||
words.push({
|
||||
value: currentWord.toLowerCase(),
|
||||
followedByOpenParen: sql[nextNonWhitespaceIndex] === "(",
|
||||
});
|
||||
currentWord = "";
|
||||
};
|
||||
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const char = sql[i]!;
|
||||
|
||||
if (quote != null) {
|
||||
if (char === "\\") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
if (sql[i + 1] === quote) {
|
||||
i += 1;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === "\"" || char === "`") {
|
||||
flushWord(i);
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comments are unnecessary in generated row filters and complicate the
|
||||
// security boundary by allowing clause-like text to be hidden from this
|
||||
// small scanner, so reject them instead of trying to interpret them.
|
||||
if (
|
||||
char === "#"
|
||||
|| char === ";"
|
||||
|| (char === "-" && sql[i + 1] === "-")
|
||||
|| (char === "/" && sql[i + 1] === "*")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (char === "(") {
|
||||
flushWord(i);
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ")") {
|
||||
flushWord(i);
|
||||
depth -= 1;
|
||||
if (depth < 0) return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && /[a-z_]/i.test(char)) {
|
||||
currentWord += char;
|
||||
} else {
|
||||
flushWord(i);
|
||||
}
|
||||
}
|
||||
|
||||
flushWord(sql.length);
|
||||
return depth === 0 && quote == null ? words : null;
|
||||
}
|
||||
|
||||
function hasOnlyTopLevelRowFilterClause(filterClause: string): boolean {
|
||||
const words = getTopLevelSqlWords(filterClause);
|
||||
if (words == null) return false;
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const word = words[i]!;
|
||||
const isAllowedFormatFunction =
|
||||
word.value === "format" && word.followedByOpenParen;
|
||||
if (
|
||||
FORBIDDEN_TOP_LEVEL_FILTER_WORDS.has(word.value)
|
||||
&& !isAllowedFormatFunction
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (let i = 1; i < words.length - 1; i++) {
|
||||
if (
|
||||
FORBIDDEN_TOP_LEVEL_FILTER_PAIRS.has(
|
||||
`${words[i]!.value} ${words[i + 1]!.value}`,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an AI-committed query is a pure row filter over the given
|
||||
* table, i.e. it cannot change the grid's columns. Accepts
|
||||
* `SELECT * FROM [default.]<table>` optionally followed by a WHERE/PREWHERE
|
||||
* row filter — anything else (column lists, JOINs, sorting, pagination, other
|
||||
* tables) returns null and must not be applied to the grid. Sorting and
|
||||
* pagination stay grid-owned so an AI filter cannot silently truncate or
|
||||
* reorder the result set before the grid processes it.
|
||||
* Subqueries inside the WHERE condition are fine: the `SELECT *` prefix on
|
||||
* the outer query is what guarantees the column set stays identical.
|
||||
*
|
||||
* Returns the query normalized for embedding as a subquery (trailing
|
||||
* semicolons stripped — the grid wraps it in `SELECT * FROM (...)`).
|
||||
*/
|
||||
export function getValidatedTableFilterQuery(
|
||||
query: string,
|
||||
tableName: string,
|
||||
): string | null {
|
||||
const normalized = query.trim().replace(/;+\s*$/, "").trim();
|
||||
if (normalized.length === 0) return null;
|
||||
const collapsed = normalized.replace(/\s+/g, " ");
|
||||
const table = escapeRegExp(tableName);
|
||||
const bareQueryPattern = new RegExp(
|
||||
`^select \\* from (?:default\\.)?\`?${table}\`?$`,
|
||||
"i",
|
||||
);
|
||||
if (bareQueryPattern.test(collapsed)) return normalized;
|
||||
|
||||
const filterQueryPattern = new RegExp(
|
||||
`^select \\* from (?:default\\.)?\`?${table}\`? ((?:where|prewhere)\\b.*)$`,
|
||||
"i",
|
||||
);
|
||||
const match = filterQueryPattern.exec(collapsed);
|
||||
if (match == null) return null;
|
||||
return hasOnlyTopLevelRowFilterClause(match[1]!) ? normalized : null;
|
||||
}
|
||||
@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowElbowDownLeftIcon,
|
||||
MagnifyingGlassIcon,
|
||||
SparkleIcon,
|
||||
SpinnerGapIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import type { RowData } from "../shared";
|
||||
import type { QueryDataGridToolbarContext } from "./query-data-grid";
|
||||
import { looksLikeNaturalLanguageQuery } from "./search-bar-logic";
|
||||
import type { AiTableFilterChat } from "./use-ai-table-filter-chat";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
type TableSearchBarProps = {
|
||||
/** Extended toolbar context from QueryDataGrid (grid state + fetch state). */
|
||||
ctx: QueryDataGridToolbarContext<RowData>,
|
||||
/**
|
||||
* The query currently driving the grid. When it changes, the grid resets
|
||||
* its own state (incl. `quickSearch`), so the bar drops any typed-but-now
|
||||
* -stale search text to stay in sync.
|
||||
*/
|
||||
queryKey: string,
|
||||
/** Chat thread powering the AI filter fallback. */
|
||||
chat: AiTableFilterChat,
|
||||
/** The validated AI row filter currently applied to the grid, or null. */
|
||||
activeFilterQuery: string | null,
|
||||
/** True when the AI committed a query that failed shape validation and was
|
||||
* therefore NOT applied. */
|
||||
filterRejected: boolean,
|
||||
/** Sends a natural-language filter request to the AI. */
|
||||
onAiSubmit: (text: string) => void,
|
||||
/** Clears the AI row filter (back to the plain table). */
|
||||
onClearFilter: () => void,
|
||||
};
|
||||
|
||||
/** Compact removable chip showing an active AI query/filter. */
|
||||
function ActiveQueryChip({
|
||||
label,
|
||||
query,
|
||||
onClear,
|
||||
clearLabel,
|
||||
}: {
|
||||
label: string,
|
||||
query: string,
|
||||
onClear: () => void,
|
||||
clearLabel: string,
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
tooltip={
|
||||
<pre className="max-w-80 whitespace-pre-wrap break-all font-mono text-[10px]">
|
||||
{query}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<span className="flex h-6 min-w-0 shrink items-center gap-1 rounded-lg bg-purple-500/10 py-0.5 pl-2 pr-1 text-[11px] text-purple-600 ring-1 ring-purple-500/25 dark:text-purple-300 dark:ring-purple-400/25">
|
||||
<SparkleIcon className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 max-w-40 truncate">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="shrink-0 rounded p-0.5 text-purple-500/70 hover:bg-purple-500/15 hover:text-purple-500 transition-colors hover:transition-none dark:text-purple-300/70 dark:hover:text-purple-200"
|
||||
aria-label={clearLabel}
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter-first search bar for the analytics tables page. Drops into the
|
||||
* DataGridToolbar in place of the built-in quick-search input.
|
||||
*
|
||||
* - Typing applies a debounced substring (ILIKE) filter over the table via
|
||||
* `state.quickSearch` — columns, sort, and pagination stay untouched.
|
||||
* - Input that a substring can't express (natural language, comparisons, or
|
||||
* searches that came back empty) is routed to the AI on Enter; the AI
|
||||
* responds with a row filter over the SAME table, shown as a removable
|
||||
* chip. ⌘/Ctrl+Enter always asks the AI.
|
||||
*/
|
||||
export function TableSearchBar({
|
||||
ctx,
|
||||
queryKey,
|
||||
chat,
|
||||
activeFilterQuery,
|
||||
filterRejected,
|
||||
onAiSubmit,
|
||||
onClearFilter,
|
||||
}: TableSearchBarProps) {
|
||||
const [input, setInput] = useState("");
|
||||
// The notice (AI text reply / rejection / error) the user has dismissed —
|
||||
// compared by content so a NEW notice shows again.
|
||||
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null);
|
||||
|
||||
const { onChange } = ctx;
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Grid query changed (AI filter/builder applied or cleared) → the grid
|
||||
// just reset its quickSearch, so typed text left in the input would look
|
||||
// applied when it isn't. Drop it and cancel any pending debounce.
|
||||
const prevQueryKeyRef = useRef(queryKey);
|
||||
useEffect(() => {
|
||||
if (prevQueryKeyRef.current === queryKey) return;
|
||||
prevQueryKeyRef.current = queryKey;
|
||||
if (debounceRef.current != null) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
setInput("");
|
||||
}, [queryKey]);
|
||||
|
||||
const applyQuickSearch = useCallback(
|
||||
(value: string) => {
|
||||
onChange((s) =>
|
||||
s.quickSearch === value
|
||||
? s
|
||||
: {
|
||||
...s,
|
||||
quickSearch: value,
|
||||
// Reset to the first page — the filtered result set may not
|
||||
// reach the page the user had scrolled to.
|
||||
pagination: { ...s.pagination, pageIndex: 0 },
|
||||
},
|
||||
);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Each keystroke re-runs the ClickHouse query, so writes to
|
||||
// `state.quickSearch` are debounced. Clearing skips the debounce — showing
|
||||
// the unfiltered table again should feel instant.
|
||||
const scheduleQuickSearch = useCallback(
|
||||
(value: string) => {
|
||||
if (debounceRef.current != null) clearTimeout(debounceRef.current);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
debounceRef.current = null;
|
||||
applyQuickSearch("");
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
applyQuickSearch(trimmed);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
},
|
||||
[applyQuickSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current != null) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flushQuickSearch = useCallback(() => {
|
||||
if (debounceRef.current != null) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
applyQuickSearch(input.trim());
|
||||
}, [applyQuickSearch, input]);
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const isNaturalLanguage = useMemo(
|
||||
() => looksLikeNaturalLanguageQuery(trimmedInput),
|
||||
[trimmedInput],
|
||||
);
|
||||
// The plain filter came back empty for exactly what's typed — a substring
|
||||
// match can't help, so offer the AI even for plain-looking terms.
|
||||
const zeroResults =
|
||||
trimmedInput.length > 0
|
||||
&& ctx.state.quickSearch === trimmedInput
|
||||
&& !ctx.isLoading
|
||||
&& !ctx.isRefetching
|
||||
&& ctx.rowCount === 0;
|
||||
const wantsAi = trimmedInput.length > 0 && (isNaturalLanguage || zeroResults);
|
||||
|
||||
const submitAi = useCallback(() => {
|
||||
const text = input.trim();
|
||||
if (text.length === 0 || chat.isResponding) return;
|
||||
if (debounceRef.current != null) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
// The text is a question for the AI, not a substring to match.
|
||||
applyQuickSearch("");
|
||||
setInput("");
|
||||
setDismissedNotice(null);
|
||||
onAiSubmit(text);
|
||||
}, [input, chat.isResponding, applyQuickSearch, onAiSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== "Enter" || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
submitAi();
|
||||
return;
|
||||
}
|
||||
if (wantsAi) {
|
||||
submitAi();
|
||||
} else {
|
||||
flushQuickSearch();
|
||||
}
|
||||
},
|
||||
[submitAi, flushQuickSearch, wantsAi],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setInput(value);
|
||||
scheduleQuickSearch(value);
|
||||
},
|
||||
[scheduleQuickSearch],
|
||||
);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setInput("");
|
||||
if (debounceRef.current != null) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
applyQuickSearch("");
|
||||
}, [applyQuickSearch]);
|
||||
|
||||
// Stored with the committed query so a rejected/text-only refinement cannot
|
||||
// relabel the still-active filter with the newer request.
|
||||
const activeFilterLabel = activeFilterQuery == null ? null : chat.latestQueryLabel;
|
||||
|
||||
const rejectionMessage = filterRejected
|
||||
? "The AI answered with a query that would change this table's columns, so it wasn't applied. Try rephrasing your request."
|
||||
: null;
|
||||
const notice = chat.error?.message ?? chat.assistantNote ?? rejectionMessage;
|
||||
const noticeVisible = notice != null && notice !== dismissedNotice;
|
||||
|
||||
const isAiActive = activeFilterQuery != null;
|
||||
const placeholder = isAiActive
|
||||
? "Search within results or refine with AI…"
|
||||
: "Search rows or ask AI…";
|
||||
|
||||
return (
|
||||
// No flex-1 on the outer wrapper — a flex-1 search would swallow the
|
||||
// toolbar's leading cluster and push siblings (e.g. the row-count badge)
|
||||
// to the far right. Width lives on the input shell instead.
|
||||
<div className="relative flex min-w-0 items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-8 w-full min-w-0 items-center gap-2 rounded-xl px-2.5 sm:w-80",
|
||||
"border border-black/[0.08] dark:border-white/[0.06]",
|
||||
"bg-white dark:bg-background shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06] transition-shadow hover:transition-none",
|
||||
isAiActive
|
||||
? "ring-purple-500/30 focus-within:ring-purple-500/50 dark:ring-purple-400/30 dark:focus-within:ring-purple-400/50"
|
||||
: "hover:ring-black/[0.12] dark:hover:ring-white/[0.1] focus-within:ring-foreground/[0.18]",
|
||||
)}
|
||||
>
|
||||
{chat.isResponding ? (
|
||||
<SpinnerGapIcon className="h-3.5 w-3.5 shrink-0 animate-spin text-purple-400" />
|
||||
) : wantsAi || isAiActive ? (
|
||||
<SparkleIcon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isAiActive ? "text-purple-400" : "text-purple-400/70",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 bg-transparent text-xs outline-none",
|
||||
"placeholder:text-muted-foreground/40",
|
||||
)}
|
||||
aria-label="Search rows or ask AI"
|
||||
/>
|
||||
{input.length > 0 && !wantsAi && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearInput}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/40 hover:text-muted-foreground transition-colors hover:transition-none"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{wantsAi && !chat.isResponding && (
|
||||
<SimpleTooltip
|
||||
tooltip={
|
||||
zeroResults && !isNaturalLanguage
|
||||
? "No rows match — press Enter to ask AI instead"
|
||||
: "Press Enter to ask AI"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitAi}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-500 hover:bg-purple-500/20 transition-colors hover:transition-none dark:text-purple-300"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
Ask AI
|
||||
<ArrowElbowDownLeftIcon className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeFilterQuery != null && activeFilterLabel != null && (
|
||||
<ActiveQueryChip
|
||||
label={activeFilterLabel}
|
||||
query={activeFilterQuery}
|
||||
onClear={onClearFilter}
|
||||
clearLabel="Clear AI filter"
|
||||
/>
|
||||
)}
|
||||
|
||||
{noticeVisible && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 flex w-max max-w-96 items-start gap-2 rounded-xl bg-white px-3 py-2 text-[11px] leading-relaxed text-foreground/80 shadow-lg ring-1 ring-black/[0.08] dark:bg-popover dark:ring-white/[0.1]">
|
||||
<SparkleIcon className="mt-0.5 h-3 w-3 shrink-0 text-purple-400" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words">{notice}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissedNotice(notice)}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/60 hover:text-foreground transition-colors hover:transition-none"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createAnalyticsQueryChatAdapter } from "@/components/vibe-coding";
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { useLocalThreadRuntime, type AssistantRuntime, type ThreadMessage, type ToolCallContentPart } from "@assistant-ui/react";
|
||||
import { useUser } from "@hexclave/next";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useProjectId } from "../../use-admin-app";
|
||||
|
||||
const QUERY_ANALYTICS_TOOL = "queryAnalytics";
|
||||
|
||||
type QueryToolPart = ToolCallContentPart<{ query?: string }, unknown>;
|
||||
|
||||
function isQueryAnalyticsToolPart(
|
||||
part: ThreadMessage["content"][number],
|
||||
): part is QueryToolPart {
|
||||
return part.type === "tool-call" && part.toolName === QUERY_ANALYTICS_TOOL;
|
||||
}
|
||||
|
||||
function isSuccessfulQueryToolPart(part: QueryToolPart): boolean {
|
||||
if (part.result == null) return false;
|
||||
if (
|
||||
typeof part.result === "object"
|
||||
&& "success" in part.result
|
||||
&& Reflect.get(part.result, "success") === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function extractLatestQuery(messages: readonly ThreadMessage[]): {
|
||||
query: string,
|
||||
toolCallIndex: number,
|
||||
} | null {
|
||||
let toolCallIndex = 0;
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.content) {
|
||||
if (isQueryAnalyticsToolPart(part)) toolCallIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (let j = msg.content.length - 1; j >= 0; j--) {
|
||||
const part = msg.content[j]!;
|
||||
if (!isQueryAnalyticsToolPart(part)) continue;
|
||||
if (!isSuccessfulQueryToolPart(part)) continue;
|
||||
const query = typeof part.args.query === "string" ? part.args.query : null;
|
||||
if (query && query.trim().length > 0) {
|
||||
return { query, toolCallIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AiQueryChat = {
|
||||
runtime: AssistantRuntime,
|
||||
messages: readonly ThreadMessage[],
|
||||
isResponding: boolean,
|
||||
error: Error | null,
|
||||
sendMessage: (input: { text: string }) => void,
|
||||
clearMessages: () => void,
|
||||
stop: () => void,
|
||||
latestQuery: string | null,
|
||||
queryGeneration: number,
|
||||
rewindToQuery: (query: string) => void,
|
||||
};
|
||||
|
||||
export function useAiQueryChat(): AiQueryChat {
|
||||
const currentUser = useUser();
|
||||
const projectId = useProjectId();
|
||||
const backendBaseUrl =
|
||||
getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ??
|
||||
getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ??
|
||||
throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set");
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const adapter = useMemo(
|
||||
() => createAnalyticsQueryChatAdapter(
|
||||
backendBaseUrl,
|
||||
currentUser ?? undefined,
|
||||
projectId,
|
||||
setError,
|
||||
),
|
||||
[backendBaseUrl, currentUser, projectId],
|
||||
);
|
||||
|
||||
const runtime = useLocalThreadRuntime(adapter, { maxSteps: 1 });
|
||||
|
||||
const [snapshot, setSnapshot] = useState(() => {
|
||||
const s = runtime.thread.getState();
|
||||
return { messages: s.messages, isRunning: s.isRunning };
|
||||
});
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const s = runtime.thread.getState();
|
||||
setSnapshot((prev) =>
|
||||
prev.messages === s.messages && prev.isRunning === s.isRunning
|
||||
? prev
|
||||
: { messages: s.messages, isRunning: s.isRunning },
|
||||
);
|
||||
};
|
||||
const unsub = runtime.thread.subscribe(update);
|
||||
update();
|
||||
return unsub;
|
||||
}, [runtime]);
|
||||
|
||||
const isResponding = snapshot.isRunning;
|
||||
const messages = snapshot.messages;
|
||||
|
||||
const [committed, setCommitted] = useState<{
|
||||
query: string,
|
||||
generation: number,
|
||||
} | null>(null);
|
||||
const wasRespondingRef = useRef(false);
|
||||
const lastCommittedGenRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const justFinished = wasRespondingRef.current && !isResponding;
|
||||
wasRespondingRef.current = isResponding;
|
||||
if (!justFinished) return;
|
||||
|
||||
const latest = extractLatestQuery(messages);
|
||||
if (latest == null) return;
|
||||
if (latest.toolCallIndex <= lastCommittedGenRef.current) return;
|
||||
lastCommittedGenRef.current = latest.toolCallIndex;
|
||||
setCommitted({ query: latest.query, generation: latest.toolCallIndex });
|
||||
}, [isResponding, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0 && committed != null) {
|
||||
lastCommittedGenRef.current = 0;
|
||||
setCommitted(null);
|
||||
}
|
||||
}, [messages.length, committed]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
({ text }: { text: string }) => {
|
||||
setError(null);
|
||||
runtime.thread.append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
},
|
||||
[runtime],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError(null);
|
||||
runtime.thread.import({ messages: [], headId: null });
|
||||
}, [runtime]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
runtime.thread.cancelRun();
|
||||
}, [runtime]);
|
||||
|
||||
const rewindToQuery = useCallback((query: string) => {
|
||||
setCommitted((prev) => ({
|
||||
query,
|
||||
generation: (prev?.generation ?? 0) + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runtime,
|
||||
messages,
|
||||
isResponding,
|
||||
error,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
stop,
|
||||
latestQuery: committed?.query ?? null,
|
||||
queryGeneration: committed?.generation ?? 0,
|
||||
rewindToQuery,
|
||||
};
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import type { ThreadMessage } from "@assistant-ui/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractLatestQuery } from "./use-ai-query-chat";
|
||||
import { extractLatestQuery } from "./use-ai-table-filter-chat";
|
||||
|
||||
const fixture = (messages: Array<{
|
||||
id: string,
|
||||
role: "assistant",
|
||||
role: "user" | "assistant",
|
||||
content: Array<
|
||||
| { type: "text", text: string }
|
||||
| { type: "tool-call", toolCallId: string, toolName: string, args: { query?: string }, argsText?: string, result: unknown }
|
||||
@ -14,6 +14,11 @@ const fixture = (messages: Array<{
|
||||
describe("extractLatestQuery", () => {
|
||||
it("ignores failed queryAnalytics tool calls and keeps the last successful query", () => {
|
||||
const messages = fixture([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "verified users" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
@ -28,6 +33,11 @@ describe("extractLatestQuery", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "only recent users" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
@ -48,7 +58,55 @@ describe("extractLatestQuery", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
query: "SELECT 1",
|
||||
toolCallIndex: 2,
|
||||
toolCallIndex: 1,
|
||||
requestText: "verified users",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse a successful query from before the supplied run", () => {
|
||||
const currentRun = fixture([
|
||||
{
|
||||
id: "user-2",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "group them by month" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "This table view cannot show grouped results." }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(extractLatestQuery(currentRun)).toBeNull();
|
||||
});
|
||||
|
||||
it("associates a successful query with the request that produced it", () => {
|
||||
const messages = fixture([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "verified users" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "queryAnalytics",
|
||||
args: { query: "SELECT * FROM users WHERE primary_email_verified = 1" },
|
||||
argsText: "",
|
||||
result: { success: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(extractLatestQuery(messages)).toEqual({
|
||||
query: "SELECT * FROM users WHERE primary_email_verified = 1",
|
||||
toolCallIndex: 1,
|
||||
requestText: "verified users",
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { createAnalyticsTableFilterChatAdapter } from "@/components/vibe-coding";
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { useLocalThreadRuntime, type ThreadMessage, type ToolCallContentPart } from "@assistant-ui/react";
|
||||
import { useUser } from "@hexclave/next";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useProjectId } from "../../use-admin-app";
|
||||
import { getValidatedTableFilterQuery } from "./search-bar-logic";
|
||||
|
||||
const QUERY_ANALYTICS_TOOL = "queryAnalytics";
|
||||
|
||||
type QueryToolPart = ToolCallContentPart<{ query?: string }, unknown>;
|
||||
|
||||
function isQueryAnalyticsToolPart(
|
||||
part: ThreadMessage["content"][number],
|
||||
): part is QueryToolPart {
|
||||
return part.type === "tool-call" && part.toolName === QUERY_ANALYTICS_TOOL;
|
||||
}
|
||||
|
||||
function isSuccessfulQueryToolPart(part: QueryToolPart): boolean {
|
||||
if (part.result == null) return false;
|
||||
if (
|
||||
typeof part.result === "object"
|
||||
&& "success" in part.result
|
||||
&& Reflect.get(part.result, "success") === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function extractLatestQuery(messages: readonly ThreadMessage[]): {
|
||||
query: string,
|
||||
toolCallIndex: number,
|
||||
requestText: string,
|
||||
} | null {
|
||||
let toolCallIndex = 0;
|
||||
let requestText: string | null = null;
|
||||
let latest: {
|
||||
query: string,
|
||||
toolCallIndex: number,
|
||||
requestText: string,
|
||||
} | null = null;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const textPart = msg.content.find((part) => part.type === "text");
|
||||
if (textPart?.type === "text" && textPart.text.trim().length > 0) {
|
||||
requestText = textPart.text.trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.content) {
|
||||
if (!isQueryAnalyticsToolPart(part)) continue;
|
||||
toolCallIndex += 1;
|
||||
if (!isSuccessfulQueryToolPart(part)) continue;
|
||||
const query = typeof part.args.query === "string" ? part.args.query : null;
|
||||
if (query && query.trim().length > 0) {
|
||||
latest = {
|
||||
query,
|
||||
toolCallIndex,
|
||||
requestText: requestText ?? throwErr(
|
||||
"queryAnalytics returned a filter without a preceding user request",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the trailing text of the last assistant message, so the UI can
|
||||
* surface what the AI said when it answered without committing a query
|
||||
* (e.g. "that's an aggregation, which this search can't display").
|
||||
*/
|
||||
function extractLastAssistantText(messages: readonly ThreadMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (let j = msg.content.length - 1; j >= 0; j--) {
|
||||
const part = msg.content[j]!;
|
||||
if (part.type === "text" && part.text.trim().length > 0) {
|
||||
return part.text.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AiTableFilterChat = {
|
||||
messages: readonly ThreadMessage[],
|
||||
isResponding: boolean,
|
||||
error: Error | null,
|
||||
sendMessage: (input: { text: string }) => void,
|
||||
clearMessages: () => void,
|
||||
/** The last validated row filter committed via the queryAnalytics tool. */
|
||||
latestQuery: string | null,
|
||||
/** The user request that produced `latestQuery`. */
|
||||
latestQueryLabel: string | null,
|
||||
/** Whether the latest run produced a query that failed row-filter validation. */
|
||||
filterRejected: boolean,
|
||||
/**
|
||||
* Set when the last run finished WITHOUT committing a new query — holds the
|
||||
* assistant's text reply (or null if there was none / a query was
|
||||
* committed). Cleared on the next send/clear or via `dismissAssistantNote`.
|
||||
*/
|
||||
assistantNote: string | null,
|
||||
dismissAssistantNote: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* AI chat thread backing the analytics table search bar's AI fallback. Uses
|
||||
* the constrained `filter-analytics-table` system prompt scoped to the given
|
||||
* table, and validates every committed tool call before exposing it as the
|
||||
* active `SELECT * FROM <table> WHERE ...` row filter.
|
||||
*/
|
||||
export function useAiTableFilterChat(tableName: string): AiTableFilterChat {
|
||||
const currentUser = useUser();
|
||||
const projectId = useProjectId();
|
||||
const backendBaseUrl =
|
||||
getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ??
|
||||
getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ??
|
||||
throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set");
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const adapter = useMemo(
|
||||
() => createAnalyticsTableFilterChatAdapter(
|
||||
backendBaseUrl,
|
||||
currentUser ?? undefined,
|
||||
projectId,
|
||||
tableName,
|
||||
setError,
|
||||
),
|
||||
[backendBaseUrl, currentUser, projectId, tableName],
|
||||
);
|
||||
|
||||
const runtime = useLocalThreadRuntime(adapter, { maxSteps: 1 });
|
||||
|
||||
const [snapshot, setSnapshot] = useState(() => {
|
||||
const s = runtime.thread.getState();
|
||||
return { messages: s.messages, isRunning: s.isRunning };
|
||||
});
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const s = runtime.thread.getState();
|
||||
setSnapshot((prev) =>
|
||||
prev.messages === s.messages && prev.isRunning === s.isRunning
|
||||
? prev
|
||||
: { messages: s.messages, isRunning: s.isRunning },
|
||||
);
|
||||
};
|
||||
const unsub = runtime.thread.subscribe(update);
|
||||
update();
|
||||
return unsub;
|
||||
}, [runtime]);
|
||||
|
||||
const isResponding = snapshot.isRunning;
|
||||
const messages = snapshot.messages;
|
||||
|
||||
const [committed, setCommitted] = useState<{
|
||||
query: string,
|
||||
label: string,
|
||||
} | null>(null);
|
||||
const [filterRejected, setFilterRejected] = useState(false);
|
||||
const [assistantNote, setAssistantNote] = useState<string | null>(null);
|
||||
const wasRespondingRef = useRef(false);
|
||||
const runStartMessageIndexRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const justFinished = wasRespondingRef.current && !isResponding;
|
||||
wasRespondingRef.current = isResponding;
|
||||
if (!justFinished) return;
|
||||
|
||||
// Each falling edge is evaluated only against messages added by that run.
|
||||
// Scanning the whole thread would let a failed/text-only refinement pick
|
||||
// up and recommit an older successful query (and its unrelated reply).
|
||||
const runMessages = messages.slice(runStartMessageIndexRef.current);
|
||||
const latest = extractLatestQuery(runMessages);
|
||||
if (latest == null) {
|
||||
// The run ended without committing a new query — surface the
|
||||
// assistant's text (if any) so callers can show WHY nothing changed.
|
||||
// This must live here (not in a consuming component) because child
|
||||
// effects run before this one; a child comparing commit state on the
|
||||
// same falling edge would race the commit below.
|
||||
setAssistantNote(extractLastAssistantText(runMessages));
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedQuery = getValidatedTableFilterQuery(latest.query, tableName);
|
||||
if (validatedQuery == null) {
|
||||
// Keep the previous valid filter active. A rejected refinement should
|
||||
// never reset the grid to its unfiltered base query or relabel the chip.
|
||||
setFilterRejected(true);
|
||||
setAssistantNote(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommitted({ query: validatedQuery, label: latest.requestText });
|
||||
setFilterRejected(false);
|
||||
setAssistantNote(null);
|
||||
}, [isResponding, messages, tableName]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
({ text }: { text: string }) => {
|
||||
setError(null);
|
||||
setAssistantNote(null);
|
||||
setFilterRejected(false);
|
||||
runStartMessageIndexRef.current = runtime.thread.getState().messages.length;
|
||||
runtime.thread.append({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
},
|
||||
[runtime],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError(null);
|
||||
setAssistantNote(null);
|
||||
setFilterRejected(false);
|
||||
setCommitted(null);
|
||||
runStartMessageIndexRef.current = 0;
|
||||
runtime.thread.import({ messages: [], headId: null });
|
||||
}, [runtime]);
|
||||
|
||||
const dismissAssistantNote = useCallback(() => {
|
||||
setAssistantNote(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isResponding,
|
||||
error,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
latestQuery: committed?.query ?? null,
|
||||
latestQueryLabel: committed?.label ?? null,
|
||||
filterRejected,
|
||||
assistantNote,
|
||||
dismissAssistantNote,
|
||||
};
|
||||
}
|
||||
@ -108,21 +108,35 @@ export function createChatAdapter(
|
||||
});
|
||||
}
|
||||
|
||||
export function createAnalyticsQueryChatAdapter(
|
||||
/**
|
||||
* Chat adapter for the analytics table search bar's AI fallback. Uses the
|
||||
* `filter-analytics-table` system prompt, which constrains the AI to row
|
||||
* filters of the shape `SELECT * FROM <table> WHERE ...` so the grid's
|
||||
* columns never change. The table being viewed is injected as a leading
|
||||
* context exchange, mirroring the source-context pattern in
|
||||
* `createChatAdapter` above.
|
||||
*/
|
||||
export function createAnalyticsTableFilterChatAdapter(
|
||||
backendBaseUrl: string,
|
||||
currentUser: CurrentUser | undefined,
|
||||
projectId: string | undefined,
|
||||
tableName: string,
|
||||
onError?: (error: Error) => void,
|
||||
): ChatModelAdapter {
|
||||
return createUnifiedAiChatAdapter({
|
||||
backendBaseUrl,
|
||||
currentUser,
|
||||
systemPrompt: "build-analytics-query",
|
||||
systemPrompt: "filter-analytics-table",
|
||||
tools: ["sql-query"],
|
||||
quality: "smart",
|
||||
speed: "fast",
|
||||
projectId,
|
||||
sanitizeContent: sanitizeAiContent,
|
||||
transformMessages: (messages) => [
|
||||
{ role: "user", content: `The table I'm viewing is \`${tableName}\`.` },
|
||||
{ role: "assistant", content: `Got it — I'll only generate row filters of the form SELECT * FROM ${tableName} WHERE ... so the grid's columns stay the same.` },
|
||||
...messages,
|
||||
],
|
||||
onError: () => {
|
||||
const wrapped = new Error("Failed to get AI response. Please try again.");
|
||||
onError?.(wrapped);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export { default as AssistantChat, type AssistantComposerApi } from './assistant-chat';
|
||||
export { createAnalyticsQueryChatAdapter, createChatAdapter, createDashboardChatAdapter, createHistoryAdapter } from './chat-adapters';
|
||||
export { createAnalyticsTableFilterChatAdapter, createChatAdapter, createDashboardChatAdapter, createHistoryAdapter } from './chat-adapters';
|
||||
export { default as CodeEditor } from './code-editor';
|
||||
export { default as VibeCodeLayout, type ViewportMode, type WysiwygDebugInfo } from './vibe-code-layout';
|
||||
|
||||
|
||||
@ -326,6 +326,8 @@ describeWithAi("AI Query Endpoint - System Prompts", () => {
|
||||
"email-assistant-draft",
|
||||
"create-dashboard",
|
||||
"run-query",
|
||||
"build-analytics-query",
|
||||
"filter-analytics-table",
|
||||
];
|
||||
|
||||
for (const systemPrompt of systemPrompts) {
|
||||
|
||||
@ -271,13 +271,13 @@ export function DataGridToolbar<TRow>({
|
||||
ctx,
|
||||
extra,
|
||||
extraLeading,
|
||||
extraActions,
|
||||
hideQuickSearch,
|
||||
}: {
|
||||
ctx: DataGridToolbarContext<TRow>;
|
||||
/** Extra content rendered inside the toolbar row, to the left of the
|
||||
* built-in columns / export actions. Use this to add table-specific
|
||||
* affordances (refresh, custom toggles, row counts) without giving up
|
||||
* the default actions. */
|
||||
/** Extra content in the leading cluster, after search / `extraLeading`
|
||||
* (filters, status badges, row counts). Stays with the search side so
|
||||
* it doesn't compete with the trailing icon actions. */
|
||||
extra?: React.ReactNode;
|
||||
/** Extra content rendered at the START of the toolbar row — occupies
|
||||
* the same position as the built-in quick search (after it, if the
|
||||
@ -285,6 +285,10 @@ export function DataGridToolbar<TRow>({
|
||||
* to fully replace the quick search with a custom input, e.g. an
|
||||
* AI-powered search bar. */
|
||||
extraLeading?: React.ReactNode;
|
||||
/** Extra content in the trailing action cluster, immediately before
|
||||
* the built-in columns / export buttons (e.g. a labeled Refresh).
|
||||
* When present, a hairline divider separates it from those icons. */
|
||||
extraActions?: React.ReactNode;
|
||||
/** Whether to hide the built-in quick-search input. When `true`,
|
||||
* callers are expected to provide their own search UI via
|
||||
* `extraLeading`. */
|
||||
@ -326,9 +330,10 @@ export function DataGridToolbar<TRow>({
|
||||
() => columns.some((c) => c.type === "date" || c.type === "dateTime"),
|
||||
[columns],
|
||||
);
|
||||
const hasExtraActions = React.Children.toArray(extraActions).length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-2 px-2.5 py-2.5 border-b border-foreground/[0.06] sm:flex-row sm:items-center sm:gap-2">
|
||||
<div className="flex w-full min-w-0 flex-col gap-2 px-2.5 py-2.5 pr-3 border-b border-foreground/[0.06] sm:flex-row sm:items-center sm:gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{!hideQuickSearch && (
|
||||
<QuickSearch
|
||||
@ -340,7 +345,16 @@ export function DataGridToolbar<TRow>({
|
||||
{extraLeading}
|
||||
{extra}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<div className="flex shrink-0 items-center justify-end gap-1.5">
|
||||
{hasExtraActions && (
|
||||
<>
|
||||
{extraActions}
|
||||
<div
|
||||
className="mx-0.5 hidden h-4 w-px bg-foreground/[0.1] sm:block"
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="relative shrink-0" ref={columnPopover.ref}>
|
||||
<ToolbarButton
|
||||
onClick={() => columnPopover.setOpen(!columnPopover.open)}
|
||||
|
||||
@ -5,7 +5,9 @@ import { useMemo, useState } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDefaultDataGridState,
|
||||
DATA_GRID_DEFAULT_STRINGS,
|
||||
DataGrid,
|
||||
DataGridToolbar,
|
||||
isDataGridInteractiveRowClickTarget,
|
||||
useDataSource,
|
||||
type DataGridColumnDef,
|
||||
@ -49,6 +51,41 @@ const wideColumns: DataGridColumnDef<Row>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe("DataGridToolbar action divider", () => {
|
||||
const ctx = {
|
||||
state: createDefaultDataGridState(columns),
|
||||
onChange: vi.fn(),
|
||||
columns,
|
||||
visibleColumns: columns,
|
||||
totalRowCount: 1,
|
||||
selectedRowCount: 0,
|
||||
strings: DATA_GRID_DEFAULT_STRINGS,
|
||||
exportCsv: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("does not render a divider for an empty React node", () => {
|
||||
const { container } = render(
|
||||
<DataGridToolbar ctx={ctx} extraActions={false} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx-0\\.5.h-4.w-px")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the divider when an action is visible", () => {
|
||||
const { container, getByRole } = render(
|
||||
<DataGridToolbar
|
||||
ctx={ctx}
|
||||
extraActions={<button type="button">Refresh</button>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByRole("button", { name: "Refresh" })).not.toBeNull();
|
||||
expect(container.querySelector(".mx-0\\.5.h-4.w-px")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
type ObserverRecord = {
|
||||
options?: IntersectionObserverInit,
|
||||
};
|
||||
@ -204,7 +241,9 @@ function InteractiveDataGridHarness(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function WideDataGridHarness() {
|
||||
function WideDataGridHarness(props: {
|
||||
horizontalScrollbarPosition?: "top" | "bottom",
|
||||
} = {}) {
|
||||
const [state, setState] = useState(() => createDefaultDataGridState(wideColumns));
|
||||
|
||||
return (
|
||||
@ -215,6 +254,7 @@ function WideDataGridHarness() {
|
||||
getRowId={(row) => row.id}
|
||||
state={state}
|
||||
onChange={setState}
|
||||
horizontalScrollbarPosition={props.horizontalScrollbarPosition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -578,4 +618,90 @@ describe("DataGrid horizontal scrolling", () => {
|
||||
expect((stickyChrome as HTMLElement).className).toContain("overflow-visible");
|
||||
expect(container.textContent).toContain("Email");
|
||||
});
|
||||
|
||||
it("puts the horizontal scrollbar under the column headers when position is top", () => {
|
||||
const { container } = render(<WideDataGridHarness horizontalScrollbarPosition="top" />);
|
||||
|
||||
const stickyChrome = container.querySelector('[role="grid"]')?.firstElementChild;
|
||||
expect(stickyChrome).toBeInstanceOf(HTMLElement);
|
||||
const headerScroll = stickyChrome?.querySelector(".overflow-x-auto");
|
||||
expect(headerScroll).toBeInstanceOf(HTMLElement);
|
||||
|
||||
const bodyScroll = container.querySelector('[role="grid"]')?.children.item(1);
|
||||
expect(bodyScroll).toBeInstanceOf(HTMLElement);
|
||||
expect((bodyScroll as HTMLElement).className).toContain("overflow-x-hidden");
|
||||
expect((bodyScroll as HTMLElement).className).toContain("overflow-y-auto");
|
||||
|
||||
Object.defineProperty(headerScroll as HTMLElement, "scrollLeft", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 0,
|
||||
});
|
||||
Object.defineProperty(bodyScroll as HTMLElement, "scrollLeft", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 0,
|
||||
});
|
||||
|
||||
(headerScroll as HTMLElement).scrollLeft = 120;
|
||||
fireEvent.scroll(headerScroll as HTMLElement);
|
||||
expect((bodyScroll as HTMLElement).scrollLeft).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGrid loading skeleton", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows a full-width shimmer when loading before any columns are known", () => {
|
||||
function SchemaPendingHarness() {
|
||||
const [state, setState] = useState(() => createDefaultDataGridState([]));
|
||||
return (
|
||||
<DataGrid<Row>
|
||||
columns={[]}
|
||||
rows={[]}
|
||||
getRowId={(row) => row.id}
|
||||
state={state}
|
||||
onChange={setState}
|
||||
isLoading
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { container } = render(<SchemaPendingHarness />);
|
||||
|
||||
const skeleton = container.querySelector("[data-data-grid-schema-pending-skeleton]");
|
||||
expect(skeleton).toBeInstanceOf(HTMLElement);
|
||||
// Five placeholder columns × 10 rows → visible shimmer cells, not an empty pane.
|
||||
expect(skeleton?.querySelectorAll("[role='row']").length).toBe(10);
|
||||
});
|
||||
|
||||
it("uses per-column skeleton rows once the schema is known", () => {
|
||||
function KnownSchemaHarness() {
|
||||
const [state, setState] = useState(() => createDefaultDataGridState(columns));
|
||||
return (
|
||||
<DataGrid<Row>
|
||||
columns={columns}
|
||||
rows={[]}
|
||||
getRowId={(row) => row.id}
|
||||
state={state}
|
||||
onChange={setState}
|
||||
isLoading
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { container } = render(<KnownSchemaHarness />);
|
||||
|
||||
expect(container.querySelector("[data-data-grid-schema-pending-skeleton]")).toBeNull();
|
||||
const rowsClip = container.querySelector("[data-data-grid-rows-clip]");
|
||||
expect(rowsClip?.querySelectorAll("[role='row']").length).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
@ -62,6 +62,12 @@ import type {
|
||||
// (no `fillHeight`, no `maxHeight`). Leaves ~16rem of room for the top bar, page header, and grid
|
||||
// toolbar. See the `effectiveMaxHeight` comment in DataGrid for why an infinite grid must be bounded.
|
||||
const DEFAULT_INFINITE_MAX_HEIGHT = "calc(100dvh - 16rem)";
|
||||
const DATA_GRID_SCROLLBAR_CLASS_NAME = cn(
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
|
||||
);
|
||||
|
||||
// ─── Row click target ────────────────────────────────────────────────
|
||||
|
||||
@ -376,6 +382,15 @@ function hashStringToInt(value: string): number {
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Column flex fractions used when the grid is loading before any schema is
|
||||
* known (e.g. QueryDataGrid discovers columns from the first result page).
|
||||
* Without these, `SkeletonRow` would render zero cells and the body looks
|
||||
* like an empty black void instead of a shimmer.
|
||||
*/
|
||||
const SCHEMA_PENDING_COL_FRACTIONS = [0.18, 0.16, 0.34, 0.2, 0.12] as const;
|
||||
const SCHEMA_PENDING_SKELETON_ROWS = 10;
|
||||
|
||||
function SkeletonRow({
|
||||
columns,
|
||||
height,
|
||||
@ -405,6 +420,72 @@ function SkeletonRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Sticky header shimmer when columns haven't been discovered yet. */
|
||||
function SchemaPendingHeaderCells({ showCheckbox }: { showCheckbox?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{showCheckbox && (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center border-r border-foreground/[0.04]"
|
||||
style={{ width: 44 }}
|
||||
/>
|
||||
)}
|
||||
{SCHEMA_PENDING_COL_FRACTIONS.map((frac, colIdx) => (
|
||||
<div
|
||||
key={colIdx}
|
||||
className="flex items-center px-3 border-r border-black/[0.04] dark:border-white/[0.04] last:border-r-0"
|
||||
style={{ width: `${frac * 100}%` }}
|
||||
>
|
||||
<DesignSkeleton
|
||||
className="h-3 rounded-md"
|
||||
style={{ width: `${45 + (colIdx * 13) % 30}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-width body shimmer when columns haven't been discovered yet. */
|
||||
function SchemaPendingSkeleton({
|
||||
rowHeight,
|
||||
showCheckbox,
|
||||
}: {
|
||||
rowHeight: number,
|
||||
showCheckbox?: boolean,
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full" data-data-grid-schema-pending-skeleton="" role="status" aria-label="Loading">
|
||||
{Array.from({ length: SCHEMA_PENDING_SKELETON_ROWS }).map((_, rowIdx) => (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className="flex w-full border-b border-black/[0.04] dark:border-white/[0.04]"
|
||||
style={{ height: rowHeight }}
|
||||
role="row"
|
||||
>
|
||||
{showCheckbox && (
|
||||
<div className="flex shrink-0 items-center justify-center border-r border-black/[0.04] dark:border-white/[0.04]" style={{ width: 44 }}>
|
||||
<DesignSkeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
)}
|
||||
{SCHEMA_PENDING_COL_FRACTIONS.map((frac, colIdx) => (
|
||||
<div
|
||||
key={colIdx}
|
||||
className="flex items-center px-3 border-r border-black/[0.04] dark:border-white/[0.04] last:border-r-0"
|
||||
style={{ width: `${frac * 100}%` }}
|
||||
>
|
||||
<DesignSkeleton
|
||||
className="h-3.5 rounded-md"
|
||||
style={{ width: `${50 + ((rowIdx * 7 + colIdx * 19) % 40)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Selection checkbox ──────────────────────────────────────────────
|
||||
|
||||
function SelectionCheckbox({
|
||||
@ -640,6 +721,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
maxHeight,
|
||||
fillHeight = true,
|
||||
stickyTop,
|
||||
horizontalScrollbarPosition = "bottom",
|
||||
toolbar,
|
||||
toolbarExtra,
|
||||
emptyState,
|
||||
@ -970,12 +1052,42 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBodyScroll = useCallback(() => {
|
||||
// Keep header + body horizontal offsets locked. When the scrollbar lives under
|
||||
// the column headers (`horizontalScrollbarPosition="top"`), the header is the
|
||||
// scroll owner and the body mirrors it (and vice versa for the default bottom bar).
|
||||
const isSyncingHorizontalScrollRef = useRef(false);
|
||||
const syncHorizontalScroll = useCallback((source: "header" | "body") => {
|
||||
const body = scrollContainerRef.current;
|
||||
const header = headerScrollRef.current;
|
||||
if (body && header) header.scrollLeft = body.scrollLeft;
|
||||
if (body == null || header == null || isSyncingHorizontalScrollRef.current) return;
|
||||
const from = source === "header" ? header : body;
|
||||
const to = source === "header" ? body : header;
|
||||
if (to.scrollLeft === from.scrollLeft) return;
|
||||
isSyncingHorizontalScrollRef.current = true;
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
isSyncingHorizontalScrollRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleBodyScroll = useCallback(() => {
|
||||
syncHorizontalScroll("body");
|
||||
}, [syncHorizontalScroll]);
|
||||
|
||||
const handleHeaderScroll = useCallback(() => {
|
||||
syncHorizontalScroll("header");
|
||||
}, [syncHorizontalScroll]);
|
||||
|
||||
const horizontalScrollbarAtTop = horizontalScrollbarPosition === "top";
|
||||
// Shared thin thumb styling. When the bar is on top, the header scrollport shows
|
||||
// it; the body keeps overflow-x hidden so a second thumb doesn't appear at the bottom.
|
||||
// Trackpad / shift-wheel horizontal gestures land on the body; with the top
|
||||
// scrollbar the body has overflow-x:hidden, so forward deltaX to the header.
|
||||
const handleBodyWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!horizontalScrollbarAtTop || event.deltaX === 0) return;
|
||||
const header = headerScrollRef.current;
|
||||
if (header == null) return;
|
||||
header.scrollLeft += event.deltaX;
|
||||
}, [horizontalScrollbarAtTop]);
|
||||
|
||||
// ── Toolbar / Footer context ─────────────────────────────────
|
||||
const toolbarCtx: DataGridToolbarContext<TRow> = useMemo(
|
||||
() => ({
|
||||
@ -1084,34 +1196,46 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
)}
|
||||
<div
|
||||
ref={headerScrollRef}
|
||||
className="w-full min-w-0 shrink-0 overflow-hidden border-b border-foreground/[0.06]"
|
||||
className={cn(
|
||||
"w-full min-w-0 shrink-0 border-b border-foreground/[0.06]",
|
||||
horizontalScrollbarAtTop
|
||||
? cn("overflow-x-auto overflow-y-hidden", DATA_GRID_SCROLLBAR_CLASS_NAME)
|
||||
: "overflow-hidden",
|
||||
)}
|
||||
onScroll={horizontalScrollbarAtTop ? handleHeaderScroll : undefined}
|
||||
>
|
||||
<div
|
||||
className="flex"
|
||||
style={{ height: headerHeight, minWidth: totalContentWidth }}
|
||||
style={{ height: headerHeight, minWidth: totalContentWidth || undefined, width: visibleColumns.length === 0 ? "100%" : undefined }}
|
||||
role="row"
|
||||
>
|
||||
{selectionMode !== "none" && (
|
||||
<div
|
||||
className="flex items-center justify-center border-r border-foreground/[0.04]"
|
||||
style={{ width: 44 }}
|
||||
>
|
||||
{selectionMode === "multiple" && (
|
||||
<SelectionCheckbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={handleSelectAll}
|
||||
ariaLabel="Select all rows on this page"
|
||||
title="Select all rows on this page"
|
||||
/>
|
||||
{isLoading && visibleColumns.length === 0 ? (
|
||||
<SchemaPendingHeaderCells showCheckbox={selectionMode !== "none"} />
|
||||
) : (
|
||||
<>
|
||||
{selectionMode !== "none" && (
|
||||
<div
|
||||
className="flex items-center justify-center border-r border-foreground/[0.04]"
|
||||
style={{ width: 44 }}
|
||||
>
|
||||
{selectionMode === "multiple" && (
|
||||
<SelectionCheckbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={handleSelectAll}
|
||||
ariaLabel="Select all rows on this page"
|
||||
title="Select all rows on this page"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visibleColumns.map((col) => {
|
||||
const header = headerByColId.get(col.id);
|
||||
if (!header) return null;
|
||||
return <HeaderCell key={col.id} header={header} col={col} resizable={resizable} />;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{visibleColumns.map((col) => {
|
||||
const header = headerByColId.get(col.id);
|
||||
if (!header) return null;
|
||||
return <HeaderCell key={col.id} header={header} col={col} resizable={resizable} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1120,14 +1244,15 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
"relative z-0 w-full min-w-0 overflow-auto bg-transparent",
|
||||
"relative z-0 w-full min-w-0 bg-transparent",
|
||||
// Top scrollbar: body is y-only so the horizontal thumb isn't duplicated
|
||||
// under the rows. scrollLeft is still set programmatically from the header.
|
||||
horizontalScrollbarAtTop ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||
isBounded ? "min-h-0 flex-1" : "flex-none",
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:h-1.5",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
"[&::-webkit-scrollbar-thumb]:bg-foreground/[0.08] [&::-webkit-scrollbar-thumb]:rounded-full",
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-foreground/[0.15]",
|
||||
DATA_GRID_SCROLLBAR_CLASS_NAME,
|
||||
)}
|
||||
onScroll={handleBodyScroll}
|
||||
onWheel={horizontalScrollbarAtTop ? handleBodyWheel : undefined}
|
||||
>
|
||||
<div
|
||||
ref={rowsClipRef}
|
||||
@ -1139,15 +1264,27 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
||||
}}
|
||||
>
|
||||
{isLoading && (
|
||||
<div style={{ minWidth: totalContentWidth }}>
|
||||
{loadingState ?? Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonRow
|
||||
key={i}
|
||||
columns={visibleColumns}
|
||||
height={estimatedRowHeight}
|
||||
showCheckbox={selectionMode !== "none"}
|
||||
/>
|
||||
))}
|
||||
<div style={{ minWidth: totalContentWidth || undefined, width: visibleColumns.length === 0 ? "100%" : undefined }}>
|
||||
{loadingState ?? (
|
||||
visibleColumns.length === 0 ? (
|
||||
// Schema not known yet (common for query grids that discover
|
||||
// columns from the first page) — fall back to a full-width
|
||||
// shimmer so loading never looks like an empty black pane.
|
||||
<SchemaPendingSkeleton
|
||||
rowHeight={estimatedRowHeight}
|
||||
showCheckbox={selectionMode !== "none"}
|
||||
/>
|
||||
) : (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonRow
|
||||
key={i}
|
||||
columns={visibleColumns}
|
||||
height={estimatedRowHeight}
|
||||
showCheckbox={selectionMode !== "none"}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -345,6 +345,13 @@ export type DataGridProps<TRow> = {
|
||||
fillHeight?: boolean;
|
||||
/** Top offset for the sticky toolbar + header (px or CSS string). */
|
||||
stickyTop?: number | string;
|
||||
/**
|
||||
* Where the horizontal scrollbar is shown when columns overflow.
|
||||
* - `"bottom"` (default): on the row scrollport, under the last visible rows.
|
||||
* - `"top"`: under the column headers (top of the table body), so wide
|
||||
* tables stay reachable without scrolling to the bottom first.
|
||||
*/
|
||||
horizontalScrollbarPosition?: "top" | "bottom";
|
||||
|
||||
// ── Callbacks ──────────────────────────────────────────────────
|
||||
} & DataGridCallbacks<TRow> & {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user