Skip to content

Commit 406b47a

Browse files
JayantDevkarclaudethe-non-expert
authored
fix: backport UX, timezone, and plugin skill detection fixes (#48)
* feat: extract and display image attachments from user messages Parses base64 image blocks from UserMessage content, surfaces them via image_attachments field, and renders them in the frontend timeline, conversation overview, and expandable prompt components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): use local timezone for dashboard stats and daily trend grouping The dashboard "today"/"yesterday"/"this week" stats and all 28 SQL daily trend queries were using UTC date boundaries instead of the machine's local timezone. On a UTC-8 machine, this caused the homepage to show 1 session instead of 24 for "today". - Add shared `local_timezone()` and `utc_to_local_date()` helpers in utils.py using `datetime.astimezone()` (DST-safe, no stale offsets) - Fix dashboard endpoint to use local calendar date boundaries - Replace all DATE(s.start_time) with timezone-adjusted expressions in db/queries.py (28 occurrences) - Fix Python-side date grouping in plugins router (5 occurrences) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(plugins): detect skills from manifest custom paths Plugins like impeccable store skills in non-default directories (e.g., .claude/skills/ instead of skills/) and declare these paths in their .claude-plugin/plugin.json manifest. Our capability scanner only checked hardcoded default directories, returning 0 skills. - Add read_plugin_manifest() and _resolve_manifest_dirs() helpers to scan both default and manifest-declared custom paths - Update scan_plugin_capabilities(), read_command_contents(), list_plugin_skills(), get_plugin_skill_content() to use them - Update _resolve_skill_info() to check manifest paths when resolving individual skill files - Fix route ordering: move /{plugin_name:path} catch-all to end so /skills and /skills/content sub-routes are reachable - Add "Browse all N skill files" link on plugin detail page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace ambiguous for/break with next() for first user message lookup The for/break pattern had the break at the same indent as the if, making it always fire on first iteration regardless of the condition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review findings across 3 cherry-picked commits - media_type allowlist for image attachments (prevents non-image data URIs) - rename shadowed `date` param to `day` in _local_day_boundaries - guard naive datetimes in utc_to_local_date (assume UTC) - remove unused plugins_base variable in _resolve_manifest_dirs - promote _find_skill_in_version_dir to module level (was needlessly nested) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: sort imports in plugins and skills routers to pass ruff I001 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: apply ruff formatter to message, plugins, and sessions modules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add zero-use skills display * feat: persist accordion state and scroll position on skills/commands pages --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com>
1 parent 27aa530 commit 406b47a

24 files changed

Lines changed: 1010 additions & 308 deletions

api/db/queries.py

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@
1818

1919
logger = logging.getLogger(__name__)
2020

21+
22+
def _local_tz_modifier() -> str:
23+
"""Return SQLite time modifier for the machine's local timezone.
24+
25+
E.g. '-480 minutes' for UTC-8 (PST), '+330 minutes' for UTC+5:30 (IST).
26+
Used in DATE()/TIME() functions to group by local calendar date.
27+
Correctly handles DST transitions via datetime.astimezone().
28+
"""
29+
from utils import local_timezone
30+
31+
offset = local_timezone().utcoffset(None)
32+
offset_min = int(offset.total_seconds() // 60)
33+
return f"{offset_min:+d} minutes"
34+
35+
36+
def _tz_date(col: str = "s.start_time") -> str:
37+
"""Return SQL expression for DATE in local timezone.
38+
39+
>>> _tz_date() # on a UTC-7 machine
40+
"DATE(s.start_time, '-420 minutes')"
41+
"""
42+
return f"DATE({col}, '{_local_tz_modifier()}')"
43+
44+
2145
# Allowlist for SQL fragments interpolated into _query_per_item_trend.
2246
# Prevents future callers from accidentally passing user input.
2347
_ALLOWED_ITEM_COLS = frozenset({"sc.command_name", "sk.skill_name", "st.tool_name", "si.subagent_type"})
@@ -40,11 +64,11 @@ def _query_per_item_trend(
4064
if item_col not in _ALLOWED_ITEM_COLS:
4165
raise ValueError(f"Disallowed item_col: {item_col!r}")
4266
rows = conn.execute(
43-
f"""SELECT {item_col} as item, DATE(s.start_time) as date, {count_expr} as count
67+
f"""SELECT {item_col} as item, {_tz_date()} as date, {count_expr} as count
4468
{from_clause}
4569
{where}
4670
{"AND" if where else "WHERE"} s.start_time IS NOT NULL
47-
GROUP BY {item_col}, DATE(s.start_time)
71+
GROUP BY {item_col}, {_tz_date()}
4872
ORDER BY item, date""",
4973
params,
5074
).fetchall()
@@ -679,14 +703,14 @@ def _query_item_detail(
679703

680704
# Daily trend
681705
trend_rows = conn.execute(
682-
f"""SELECT DATE(s.start_time) as date,
706+
f"""SELECT {_tz_date()} as date,
683707
SUM({alias}.count) as calls,
684708
COUNT(DISTINCT {alias}.session_uuid) as sessions
685709
FROM {table} {alias}
686710
JOIN sessions s ON {alias}.session_uuid = s.uuid
687711
WHERE {alias}.{item_col} = :{param_name} AND s.start_time IS NOT NULL
688712
{mention_exclusion}
689-
GROUP BY DATE(s.start_time)
713+
GROUP BY {_tz_date()}
690714
ORDER BY date""",
691715
item_param,
692716
).fetchall()
@@ -982,11 +1006,11 @@ def _query_item_usage_trend(
9821006
# Daily trend
9831007
and_or_where = "AND" if where_items else "WHERE"
9841008
trend_rows = conn.execute(
985-
f"""SELECT DATE(s.start_time) as date, SUM({table}.count) as count
1009+
f"""SELECT {_tz_date()} as date, SUM({table}.count) as count
9861010
{from_clause}
9871011
{where_items}
9881012
{and_or_where} s.start_time IS NOT NULL
989-
GROUP BY DATE(s.start_time)
1013+
GROUP BY {_tz_date()}
9901014
ORDER BY date""",
9911015
params,
9921016
).fetchall()
@@ -1775,12 +1799,12 @@ def query_agent_usage_trend(
17751799

17761800
# Daily trend
17771801
trend_rows = conn.execute(
1778-
f"""SELECT DATE(s.start_time) as date, COUNT(*) as count
1802+
f"""SELECT {_tz_date()} as date, COUNT(*) as count
17791803
FROM subagent_invocations si
17801804
JOIN sessions s ON si.session_uuid = s.uuid
17811805
{where}
17821806
AND s.start_time IS NOT NULL
1783-
GROUP BY DATE(s.start_time)
1807+
GROUP BY {_tz_date()}
17841808
ORDER BY date""",
17851809
params,
17861810
).fetchall()
@@ -2660,13 +2684,13 @@ def query_mcp_server_trend(
26602684

26612685
# Main session calls per day
26622686
main_rows = conn.execute(
2663-
f"""SELECT DATE(s.start_time) as date,
2687+
f"""SELECT {_tz_date()} as date,
26642688
SUM(st.count) as calls,
26652689
COUNT(DISTINCT st.session_uuid) as sessions
26662690
FROM session_tools st
26672691
JOIN sessions s ON st.session_uuid = s.uuid
26682692
{where}
2669-
GROUP BY DATE(s.start_time)""",
2693+
GROUP BY {_tz_date()}""",
26702694
params,
26712695
).fetchall()
26722696

@@ -2684,14 +2708,14 @@ def query_mcp_server_trend(
26842708
sub_where = "WHERE " + " AND ".join(sub_conditions)
26852709

26862710
sub_rows = conn.execute(
2687-
f"""SELECT DATE(s.start_time) as date,
2711+
f"""SELECT {_tz_date()} as date,
26882712
SUM(sat.count) as calls,
26892713
COUNT(DISTINCT si.session_uuid) as sessions
26902714
FROM subagent_tools sat
26912715
JOIN subagent_invocations si ON sat.invocation_id = si.id
26922716
JOIN sessions s ON si.session_uuid = s.uuid
26932717
{sub_where}
2694-
GROUP BY DATE(s.start_time)""",
2718+
GROUP BY {_tz_date()}""",
26952719
sub_params,
26962720
).fetchall()
26972721

@@ -2919,12 +2943,12 @@ def query_mcp_tool_usage_trend(
29192943

29202944
# Daily trend
29212945
trend_rows = conn.execute(
2922-
f"""SELECT DATE(s.start_time) as date, SUM(st.count) as count
2946+
f"""SELECT {_tz_date()} as date, SUM(st.count) as count
29232947
FROM session_tools st
29242948
JOIN sessions s ON st.session_uuid = s.uuid
29252949
{where}
29262950
AND s.start_time IS NOT NULL
2927-
GROUP BY DATE(s.start_time)
2951+
GROUP BY {_tz_date()}
29282952
ORDER BY date""",
29292953
params,
29302954
).fetchall()
@@ -3034,12 +3058,12 @@ def query_builtin_tool_usage_trend(
30343058

30353059
# Daily trend
30363060
trend_rows = conn.execute(
3037-
f"""SELECT DATE(s.start_time) as date, SUM(st.count) as count
3061+
f"""SELECT {_tz_date()} as date, SUM(st.count) as count
30383062
FROM session_tools st
30393063
JOIN sessions s ON st.session_uuid = s.uuid
30403064
{where}
30413065
AND s.start_time IS NOT NULL
3042-
GROUP BY DATE(s.start_time)
3066+
GROUP BY {_tz_date()}
30433067
ORDER BY date""",
30443068
params,
30453069
).fetchall()
@@ -3147,13 +3171,13 @@ def query_mcp_tool_detail(
31473171
trend_where = "WHERE " + " AND ".join(trend_conditions)
31483172

31493173
trend_rows = conn.execute(
3150-
f"""SELECT DATE(s.start_time) as date,
3174+
f"""SELECT {_tz_date()} as date,
31513175
SUM(st.count) as calls,
31523176
COUNT(DISTINCT st.session_uuid) as sessions
31533177
FROM session_tools st
31543178
JOIN sessions s ON st.session_uuid = s.uuid
31553179
{trend_where}
3156-
GROUP BY DATE(s.start_time)""",
3180+
GROUP BY {_tz_date()}""",
31573181
params,
31583182
).fetchall()
31593183

@@ -3162,13 +3186,13 @@ def query_mcp_tool_detail(
31623186
sub_trend_where = "WHERE " + " AND ".join(sub_trend_conditions)
31633187

31643188
sub_trend_rows = conn.execute(
3165-
f"""SELECT DATE(s.start_time) as date,
3189+
f"""SELECT {_tz_date()} as date,
31663190
SUM(sat.count) as calls
31673191
FROM subagent_tools sat
31683192
JOIN subagent_invocations si ON sat.invocation_id = si.id
31693193
JOIN sessions s ON si.session_uuid = s.uuid
31703194
{sub_trend_where}
3171-
GROUP BY DATE(s.start_time)""",
3195+
GROUP BY {_tz_date()}""",
31723196
sub_params,
31733197
).fetchall()
31743198

@@ -3503,13 +3527,13 @@ def query_builtin_server_trend(
35033527
where = "WHERE " + " AND ".join(conditions)
35043528

35053529
main_rows = conn.execute(
3506-
f"""SELECT DATE(s.start_time) as date,
3530+
f"""SELECT {_tz_date()} as date,
35073531
SUM(st.count) as calls,
35083532
COUNT(DISTINCT st.session_uuid) as sessions
35093533
FROM session_tools st
35103534
JOIN sessions s ON st.session_uuid = s.uuid
35113535
{where}
3512-
GROUP BY DATE(s.start_time)""",
3536+
GROUP BY {_tz_date()}""",
35133537
params,
35143538
).fetchall()
35153539

@@ -3526,14 +3550,14 @@ def query_builtin_server_trend(
35263550
sub_where = "WHERE " + " AND ".join(sub_conditions)
35273551

35283552
sub_rows = conn.execute(
3529-
f"""SELECT DATE(s.start_time) as date,
3553+
f"""SELECT {_tz_date()} as date,
35303554
SUM(sat.count) as calls,
35313555
COUNT(DISTINCT si.session_uuid) as sessions
35323556
FROM subagent_tools sat
35333557
JOIN subagent_invocations si ON sat.invocation_id = si.id
35343558
JOIN sessions s ON si.session_uuid = s.uuid
35353559
{sub_where}
3536-
GROUP BY DATE(s.start_time)""",
3560+
GROUP BY {_tz_date()}""",
35373561
sub_params,
35383562
).fetchall()
35393563

@@ -3632,13 +3656,13 @@ def query_builtin_tool_detail(
36323656
trend_where = "WHERE " + " AND ".join(trend_conditions)
36333657

36343658
trend_rows = conn.execute(
3635-
f"""SELECT DATE(s.start_time) as date,
3659+
f"""SELECT {_tz_date()} as date,
36363660
SUM(st.count) as calls,
36373661
COUNT(DISTINCT st.session_uuid) as sessions
36383662
FROM session_tools st
36393663
JOIN sessions s ON st.session_uuid = s.uuid
36403664
{trend_where}
3641-
GROUP BY DATE(s.start_time)""",
3665+
GROUP BY {_tz_date()}""",
36423666
params,
36433667
).fetchall()
36443668

@@ -3647,13 +3671,13 @@ def query_builtin_tool_detail(
36473671
sub_trend_where = "WHERE " + " AND ".join(sub_trend_conditions)
36483672

36493673
sub_trend_rows = conn.execute(
3650-
f"""SELECT DATE(s.start_time) as date,
3674+
f"""SELECT {_tz_date()} as date,
36513675
SUM(sat.count) as calls
36523676
FROM subagent_tools sat
36533677
JOIN subagent_invocations si ON sat.invocation_id = si.id
36543678
JOIN sessions s ON si.session_uuid = s.uuid
36553679
{sub_trend_where}
3656-
GROUP BY DATE(s.start_time)""",
3680+
GROUP BY {_tz_date()}""",
36573681
sub_params,
36583682
).fetchall()
36593683

api/models/message.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ class UserMessage(MessageBase):
7171
default=False, description="True if content was a tool_result wrapper"
7272
)
7373
tool_result_id: Optional[str] = Field(default=None, description="tool_use_id if is_tool_result")
74+
image_attachments: List[Dict[str, str]] = Field(
75+
default_factory=list, description="Image attachments extracted from content blocks"
76+
)
7477
is_internal_message: bool = Field(
7578
default=False,
7679
description="True if content is internal (local commands, task notifications, etc)",
@@ -99,16 +102,39 @@ def _extract_nested_content(cls, data: Any) -> Any:
99102
data["tool_result_id"] = part.get("tool_use_id")
100103
break
101104
parts = []
105+
images = []
102106
for part in content:
103107
if isinstance(part, dict):
108+
if part.get("type") == "image":
109+
source = part.get("source", {})
110+
if source.get("type") == "base64":
111+
_ALLOWED_IMAGE_TYPES = {
112+
"image/png",
113+
"image/jpeg",
114+
"image/gif",
115+
"image/webp",
116+
"image/svg+xml",
117+
}
118+
mt = source.get("media_type", "image/png")
119+
if mt not in _ALLOWED_IMAGE_TYPES:
120+
mt = "image/png"
121+
images.append(
122+
{
123+
"media_type": mt,
124+
"data": source.get("data", ""),
125+
}
126+
)
127+
continue # Don't stringify image blocks
104128
text = part.get("text") or part.get("content")
105129
if isinstance(text, str):
106130
parts.append(text)
107131
else:
108132
parts.append(str(part))
109133
elif isinstance(part, str):
110134
parts.append(part)
111-
content = "\n".join(parts) or str(content)
135+
content = "\n".join(parts) or ""
136+
if images:
137+
data["image_attachments"] = images
112138
# Detect internal message patterns on the extracted text
113139
_detect_internal_message(data, content if isinstance(content, str) else "")
114140
return {**data, "content": content}

0 commit comments

Comments
 (0)