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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 121 additions & 122 deletions app/templates/chat/index.html
Original file line number Diff line number Diff line change
@@ -1,131 +1,130 @@
<%extends "base.html" %}
{% extends "base.html" %}

{% block title %}Chat - {{ config.APP_NAME }}{% endblock %}

{% block content %}
<div class="chat-layout">
2<aside class="chat-sidebar">
3 <div class="sidebar-header">
4 <h2>Conversations</h2>
5 <button id="new-conversation" class="btn btn-primary btn-sm" type="button">+​ New</button>
6 </div>
7 <input id="conversation-search" class="sidebar-search" type="search" placeholder="Search conversations...">
8 <ul id="conversation-list" class="conversation-list" aria-label="Conversations">
9 {% for conversation in conversations %}
10 <li class="conversation-item{% if loop.first %} active{% endif %}"
11 data-id="{{ conversation.id }}"
12 data-title="{{ conversation.title }}"
13 tabindex="0"
14 role="button"
15 aria-label="Open conversation: {{ conversation.title }}"
16 {% if loop.first %}aria-current="true"{% endif %}>
17 <span class="conversation-title">{{ conversation.title }}</span>
18 <span class="conversation-meta">{{ conversation.message_count }} messages <time class="conversation-time" datetime="{{ conversation.updated_at.isoformat() if conversation.updated_at else '' }}"></time></span>
19 <span class="conversation-status" hidden aria-hidden="true">…</span>
20 </li>
21 {% endfor %}
22 </ul>
23 {% if not conversations %}
24 <p class="sidebar-empty">No conversations yet. Start chatting below!</p>
25 {% endif %}
26
27 <section class="file-tree-panel">
28 <div class="sidebar-header">
29 <h2>Project files</h2>
30 </div>
31 <div id="project-file-tree" class="file-tree">
32 <p class="sidebar-empty">Loading files...</p>
33 </div>
34 <div id="project-file-viewer" class="file-viewer" hidden>
35 <div class="file-viewer-header">
36 <button id="project-file-back" class="btn btn-ghost btn-sm" type="button">&larr; Back</button>
37 <button id="project-file-ask" class="btn btn-primary btn-sm" type="button">Ask about this file</button>
38 </div>
39 <p id="project-file-meta" class="file-viewer-meta"></p>
40 <pre id="project-file-content" class="file-viewer-content"></pre>
41 </div>
42 </section>
43 </aside>
44
45 <section class="chat-main">
46 <div id="chat-messages" class="chat-messages" role="log" aria-live="polite" aria-label="Conversation messages">
47 <div class="chat-placeholder">
48 <p>Start a conversation with the AI assistant.</p>
49 </div>
50 </div>
51
52 {% if not provider_status.configured %}
53 <div id="provider-onboarding" class="onboarding-panel" role="region"
54 aria-label="Provider setup"
55 data-provider="{{ provider_status.provider }}"
56 data-reason="{{ provider_status.reason }}">
57 <h3>Add a provider API key to start chatting</h3>
58 <p>
59 This assistant uses the <strong>{{ provider_status.provider }}</strong> provider,
60 which needs an API key before it can reply. You can still create and browse
61 conversations in the meantime.
62 </p>
63 <ol>
64 <li>Open the <a href="{{ url_for('keys.index') }}">API keys</a> page.</li>
65 <li>Add a key for <code>{{ provider_status.provider }}</code>.</li>
66 <li>Return here and press <em>Check again</em> — no reload needed.</li>
67 </ol>
68 <div class="onboarding-actions">
69 <a class="btn btn-primary" href="{{ url_for('keys.index') }}">Manage API keys</a>
70 <button id="provider-recheck" class="btn btn-ghost" type="button">Check again</button>
71 </div>
72 <p id="provider-onboarding-note" class="chat-hint"></p>
73 </div>
74 {% endif %}
75
76 <details id="chat-settings" class="chat-settings">
77 <summary>Model &amp; generation settings</summary>
78 <div class="chat-settings-body">
79 <div class="settings-field">
80 <label class="field-hint" for="chat-provider">Provider</label>
81 <select id="chat-provider" class="field-input"></select>
82 </div>
83 <div class="settings-field">
84 <label class="field-hint" for="chat-model">Model</label>
85 <select id="chat-model" class="field-input"></select>
86 </div>
87 <div class="settings-field">
88 <label class="field-hint" for="chat-temperature">Temperature: <span id="chat-temperature-value">0.7</span></label>
89 <input id="chat-temperature" type="range" min="0" max="2" step="0.1" value="0.7">
90 </div>
91 <div class="settings-field">
92 <label class="field-hint" for="chat-system-prompt">System prompt override (optional)</label>
93 <textarea id="chat-system-prompt" class="field-input" rows="2" maxlength="4000" placeholder="For example: answer with concise TypeScript examples."></textarea>
94 </div>
95 <p class="chat-hint" id="chat-settings-hint">Providers without a key are disabled. <a href="{{ url_for('keys.index') }}">Manage API keys</a>.</p>
96 </div>
97 </details>
98
99 <div class="chat-composer">
100 <div id="chat-image-preview" class="chat-image-preview" aria-live="polite"></div>
101 <textarea id="chat-input" rows="3" aria-label="Message" aria-describedby="composer-error chat-composer-hint" placeholder="Ask for code, explanations, or help..."></textarea>
102 <input id="chat-image-input" type="file" accept="image/png,image/jpeg,image/webp" multiple hidden>
103 <div class="composer-actions">
104 <button id="chat-attach-image" class="btn btn-ghost btn-sm" type="button">+ Image</button>
105 <button id="send-message" class="btn btn-primary" type="button" aria-label="Send message">Send</button>
106 </div>
107 <p id="composer-error" class="composer-error" role="alert" hidden></p>
108 <p id="conversation-usage" class="chat-hint" aria-live="polite"></p>
109 <p id="chat-composer-hint" class="chat-hint">Press Enter to send, Shift+Enter for a new line. The assistant streams its reply. Markdown and code blocks are supported. Attach up to 4 png/jpeg/webp images.</p>
110 </div>
111 </section>
112 </div>
113
114 <div id="conversation-actions" class="conversation-actions" hidden>
115 <button id="rename-conversation" class="btn btn-ghost btn-sm" type="button">Rename</button>
116 <button id="pin-conversation" class="btn btn-ghost btn-sm" type="button">Pin</button>
117 <button id="share-conversation" class="btn btn-ghost btn-sm" type="button">Share</button>
118 <button id="create-share-link" class="btn btn-ghost btn-sm" type="button">Create read-only link</button>
119 <button id="export-conversation" class="btn btn-ghost btn-sm" type="button">Export</button>
120 <button id="delete-conversation" class="btn btn-ghost btn-sm btn-danger" type="button">Delete</button>
121 <div id="share-links-list" aria-live="polite"></div>
122 </div>
<aside class="chat-sidebar">
<div class="sidebar-header">
<h2>Conversations</h2>
<button id="new-conversation" class="btn btn-primary btn-sm" type="button">+ New</button>
</div>
<input id="conversation-search" class="sidebar-search" type="search" placeholder="Search conversations...">
<ul id="conversation-list" class="conversation-list" aria-label="Conversations">
{% for conversation in conversations %}
<li class="conversation-item{% if loop.first %} active{% endif %}"
data-id="{{ conversation.id }}"
data-title="{{ conversation.title }}"
tabindex="0"
role="button"
aria-label="Open conversation: {{ conversation.title }}"
{% if loop.first %}aria-current="true"{% endif %}>
<span class="conversation-title">{{ conversation.title }}</span>
<span class="conversation-meta">{{ conversation.message_count }} messages <time class="conversation-time" datetime="{{ conversation.updated_at.isoformat() if conversation.updated_at else '' }}"></time></span>
<span class="conversation-status" hidden aria-hidden="true"></span>
</li>
{% endfor %}
</ul>
{% if not conversations %}
<p class="sidebar-empty">No conversations yet. Start chatting below!</p>
{% endif %}

<section class="file-tree-panel">
<div class="sidebar-header">
<h2>Project files</h2>
</div>
<div id="project-file-tree" class="file-tree">
<p class="sidebar-empty">Loading files...</p>
</div>
<div id="project-file-viewer" class="file-viewer" hidden>
<div class="file-viewer-header">
<button id="project-file-back" class="btn btn-ghost btn-sm" type="button">&larr; Back</button>
<button id="project-file-ask" class="btn btn-primary btn-sm" type="button">Ask about this file</button>
</div>
<p id="project-file-meta" class="file-viewer-meta"></p>
<pre id="project-file-content" class="file-viewer-content"></pre>
</div>
</section>
</aside>

<section class="chat-main">
<div id="chat-messages" class="chat-messages" role="log" aria-live="polite" aria-label="Conversation messages">
<div class="chat-placeholder">
<p>Start a conversation with the AI assistant.</p>
</div>
</div>

{% if not provider_status.configured %}
<div id="provider-onboarding" class="onboarding-panel" role="region"
aria-label="Provider setup"
data-provider="{{ provider_status.provider }}"
data-reason="{{ provider_status.reason }}">
<h3>Add a provider API key to start chatting</h3>
<p>
This assistant uses the <strong>{{ provider_status.provider }}</strong> provider,
which needs an API key before it can reply. You can still create and browse
conversations in the meantime.
</p>
<ol>
<li>Open the <a href="{{ url_for('keys.index') }}">API keys</a> page.</li>
<li>Add a key for <code>{{ provider_status.provider }}</code>.</li>
<li>Return here and press <em>Check again</em> — no reload needed.</li>
</ol>
<div class="onboarding-actions">
<a class="btn btn-primary" href="{{ url_for('keys.index') }}">Manage API keys</a>
<button id="provider-recheck" class="btn btn-ghost" type="button">Check again</button>
</div>
<p id="provider-onboarding-note" class="chat-hint"></p>
</div>
{% endif %}

<details id="chat-settings" class="chat-settings">
<summary>Model &amp; generation settings</summary>
<div class="chat-settings-body">
<div class="settings-field">
<label class="field-hint" for="chat-provider">Provider</label>
<select id="chat-provider" class="field-input"></select>
</div>
<div class="settings-field">
<label class="field-hint" for="chat-model">Model</label>
<select id="chat-model" class="field-input"></select>
</div>
<div class="settings-field">
<label class="field-hint" for="chat-temperature">Temperature: <span id="chat-temperature-value">0.7</span></label>
<input id="chat-temperature" type="range" min="0" max="2" step="0.1" value="0.7">
</div>
<div class="settings-field">
<label class="field-hint" for="chat-system-prompt">System prompt override (optional)</label>
<textarea id="chat-system-prompt" class="field-input" rows="2" maxlength="4000" placeholder="For example: answer with concise TypeScript examples."></textarea>
</div>
<p class="chat-hint" id="chat-settings-hint">Providers without a key are disabled. <a href="{{ url_for('keys.index') }}">Manage API keys</a>.</p>
</div>
</details>

<div class="chat-composer">
<div id="chat-image-preview" class="chat-image-preview" aria-live="polite"></div>
<textarea id="chat-input" rows="3" aria-label="Message" aria-describedby="composer-error chat-composer-hint" placeholder="Ask for code, explanations, or help..."></textarea>
<input id="chat-image-input" type="file" accept="image/png,image/jpeg,image/webp" multiple hidden>
<div class="composer-actions">
<button id="chat-attach-image" class="btn btn-ghost btn-sm" type="button">+ Image</button>
<button id="send-message" class="btn btn-primary" type="button" aria-label="Send message">Send</button>
</div>
<p id="composer-error" class="composer-error" role="alert" hidden></p>
<p id="conversation-usage" class="chat-hint" aria-live="polite"></p>
<p id="chat-composer-hint" class="chat-hint">Press Enter to send, Shift+Enter for a new line. The assistant streams its reply. Markdown and code blocks are supported. Attach up to 4 png/jpeg/webp images.</p>
</div>
</section>
</div>

<div id="conversation-actions" class="conversation-actions" hidden>
<button id="rename-conversation" class="btn btn-ghost btn-sm" type="button">Rename</button>
<button id="pin-conversation" class="btn btn-ghost btn-sm" type="button">Pin</button>
<button id="share-conversation" class="btn btn-ghost btn-sm" type="button">Share</button>
<button id="create-share-link" class="btn btn-ghost btn-sm" type="button">Create read-only link</button>
<button id="export-conversation" class="btn btn-ghost btn-sm" type="button">Export</button>
<button id="delete-conversation" class="btn btn-ghost btn-sm btn-danger" type="button">Delete</button>
<div id="share-links-list" aria-live="polite"></div>
</div>
{% endblock %}

{% block scripts %}
Expand Down
44 changes: 44 additions & 0 deletions tests/test_template_hygiene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Template hygiene checks.

A squash-merged change once landed a Jinja template whose lines were prefixed
with their own line numbers (``2<aside class="chat-sidebar">``). The template
still compiled, so nothing failed; the digits simply rendered into the page.
These checks walk every template in the project and fail when a line looks like
numbered source rather than markup.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = REPO_ROOT / "app" / "templates"

#: A line that starts with a 1-4 digit number immediately followed by markup.
_NUMBERED_MARKUP_RE = re.compile(r"^\d{1,4}\s*<")


def _templates() -> list[Path]:
return sorted(TEMPLATES.rglob("*.html"))


@pytest.mark.parametrize("template", _templates(), ids=lambda p: str(p.relative_to(REPO_ROOT)))
def test_templates_have_no_line_number_prefixes(template: Path):
offenders = [
number
for number, line in enumerate(template.read_text(encoding="utf-8").splitlines(), start=1)
if _NUMBERED_MARKUP_RE.match(line)
]
assert (
not offenders
), f"{template.relative_to(REPO_ROOT)} contains line-numbered markup on lines {offenders}"


def test_chat_template_extends_the_base_layout():
"""The chat page must extend the shared layout, not open with markup."""
text = (TEMPLATES / "chat" / "index.html").read_text(encoding="utf-8")
assert text.startswith('{% extends "base.html" %}')
assert "conversation-status" in text
Loading