diff --git a/static/sessions.js b/static/sessions.js index 4d32674..63dc758 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -260,11 +260,11 @@ function renderSessionListFromCache(){ pinInd.innerHTML=ICONS.pin; el.appendChild(pinInd); } - // Project indicator: colored left border + // Project indicator: colored left border (active item keeps its own gold color) if(s.project_id){ const proj=_allProjects.find(p=>p.project_id===s.project_id); if(proj){ - el.style.borderLeftColor=proj.color||'var(--blue)'; + if(!isActive) el.style.borderLeftColor=proj.color||'var(--blue)'; const dot=document.createElement('span'); dot.className='session-project-dot'; dot.style.background=proj.color||'var(--blue)'; diff --git a/static/style.css b/static/style.css index 29003f3..0113b0a 100644 --- a/static/style.css +++ b/static/style.css @@ -20,14 +20,14 @@ .session-search input::placeholder{color:var(--muted);opacity:.7;} /* Inline session title edit */ .session-title-input{flex:1;background:rgba(20,32,60,.9);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;} - .session-item{padding:8px 10px 8px 8px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;position:relative;} + .session-item{padding:8px 10px 8px 8px;border-radius:0 8px 8px 0;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;position:relative;} .session-item:hover{background:rgba(255,255,255,0.06);color:var(--text);} - .session-item.active{background:rgba(124,185,255,0.1);color:var(--blue);border-left:2px solid var(--blue);padding-left:8px;} + .session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;border-left:2px solid #e8a030;padding-left:8px;} .session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} /* ── Session action button overlay ── */ .session-actions{position:absolute;right:0;top:0;bottom:0;display:flex;align-items:center;gap:2px;padding:0 6px 0 16px;background:linear-gradient(to right,transparent,var(--sidebar) 12px);opacity:0;pointer-events:none;transition:opacity .15s ease;border-radius:0 8px 8px 0;} .session-item:hover .session-actions{opacity:1;pointer-events:auto;} - .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(16,33,62,.95) 12px);} + .session-item.active .session-actions{background:linear-gradient(to right,transparent,rgba(30,22,8,.95) 12px);} .session-actions button{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px 3px;line-height:1;transition:color .12s;display:flex;align-items:center;} .session-actions button:hover{color:var(--text);} .session-actions .act-trash:hover{color:var(--accent);} diff --git a/static/ui.js b/static/ui.js index 7ea328e..97bb8fc 100644 --- a/static/ui.js +++ b/static/ui.js @@ -81,6 +81,22 @@ function getModelLabel(modelId){ function renderMd(raw){ let s=raw||''; + // Pre-pass: convert safe inline HTML tags the model may emit into their + // markdown equivalents so the pipeline can render them correctly. + // Only runs OUTSIDE fenced code blocks and backtick spans (stash + restore). + // Unsafe tags (anything not in the allowlist) are left as-is and will be + // HTML-escaped by esc() when they reach an innerHTML assignment -- no XSS risk. + const fence_stash=[]; + s=s.replace(/(```[\s\S]*?```|`[^`\n]+`)/g,m=>{fence_stash.push(m);return '\x00F'+(fence_stash.length-1)+'\x00';}); + // Safe tag → markdown equivalent (these produce the same output as **text** etc.) + s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**'); + s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**'); + s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*'); + s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*'); + s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`'); + s=s.replace(//gi,'\n'); + // Restore stashed code blocks + s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]); // Mermaid blocks: render as diagram containers (processed after DOM insertion) s=s.replace(/```mermaid\n?([\s\S]*?)```/g,(_,code)=>{ const id='mermaid-'+Math.random().toString(36).slice(2,10); @@ -88,12 +104,27 @@ function renderMd(raw){ }); s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`
${esc(lang)}
`:'';return `${h}
${esc(code.replace(/\n$/,''))}
`;}); s=s.replace(/`([^`\n]+)`/g,(_,c)=>`${esc(c)}`); + // inlineMd: process bold/italic/code/links within a single line of text. + // Used inside list items and blockquotes where the text may already contain + // HTML from the pre-pass → bold pipeline, so we cannot call esc() directly. + function inlineMd(t){ + t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`); + t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`); + t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`); + t=t.replace(/`([^`\n]+)`/g,(_,x)=>`${esc(x)}`); + t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>`${esc(lb)}`); + // Escape any plain text that isn't already wrapped in a tag we produced + // by escaping bare < > that aren't part of our own tags + const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i; + t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); + return t; + } s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`); - s=s.replace(/^### (.+)$/gm,(_,t)=>`

${esc(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${esc(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${esc(t)}

`); + s=s.replace(/^### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`); s=s.replace(/^---+$/gm,'
'); - s=s.replace(/^> (.+)$/gm,(_,t)=>`
${esc(t)}
`); + s=s.replace(/^> (.+)$/gm,(_,t)=>`
${inlineMd(t)}
`); // B8: improved list handling supporting up to 2 levels of indentation s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{ const lines=block.trimEnd().split('\n'); @@ -101,8 +132,8 @@ function renderMd(raw){ for(const l of lines){ const indent=/^ {2,}/.test(l); const text=l.replace(/^ {0,4}[-*+] /,''); - if(indent) html+=`
  • ${esc(text)}
  • `; - else html+=`
  • ${esc(text)}
  • `; + if(indent) html+=`
  • ${inlineMd(text)}
  • `; + else html+=`
  • ${inlineMd(text)}
  • `; } return html+''; }); @@ -111,7 +142,7 @@ function renderMd(raw){ let html='
      '; for(const l of lines){ const text=l.replace(/^ {0,4}\d+\. /,''); - html+=`
    1. ${esc(text)}
    2. `; + html+=`
    3. ${inlineMd(text)}
    4. `; } return html+'
    '; }); @@ -128,6 +159,12 @@ function renderMd(raw){ const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); return `${header}${body}
    `; }); + // Escape any remaining HTML tags that are NOT from our own markdown output. + // Our pipeline only emits: ,,,
    ,,
      ,
        ,
      1. , + // ,,,,
        ,,
        ,
        ,

        ,
        ,, + //

        (mermaid/pre-header). Everything else is untrusted input. + const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div)([\s>]|$)/i; + s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag)); const parts=s.split(/\n{2,}/); s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `

        ${p.replace(/\n/g,'
        ')}

        `;}).join('\n'); return s; diff --git a/tests/test_sprint16.py b/tests/test_sprint16.py new file mode 100644 index 0000000..542a32f --- /dev/null +++ b/tests/test_sprint16.py @@ -0,0 +1,709 @@ +""" +Sprint 16 Tests: safe HTML rendering in renderMd(), active session styling, +session sidebar polish (SVG icons, overlay actions). +""" +import html as _html +import pathlib +import re +import urllib.request + +BASE = "http://127.0.0.1:8788" +REPO_ROOT = pathlib.Path(__file__).parent.parent + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def get_text(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return r.read().decode("utf-8"), r.status + + +def esc(s): + """Mirror of esc() in ui.js — HTML-escapes a string.""" + return _html.escape(str(s), quote=True) + + +SAFE_TAGS = re.compile( + r"^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td" + r"|hr|blockquote|p|br|a|div)([\s>]|$)", + re.I, +) +SAFE_INLINE = re.compile(r"^<\/?(strong|em|code|a)([\s>]|$)", re.I) + + +def inline_md(t): + """Mirror of inlineMd() in ui.js — for use inside list items / blockquotes.""" + t = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: "" + esc(m.group(1)) + "", t) + t = re.sub(r"\*\*(.+?)\*\*", lambda m: "" + esc(m.group(1)) + "", t) + t = re.sub(r"\*([^*\n]+)\*", lambda m: "" + esc(m.group(1)) + "", t) + t = re.sub(r"`([^`\n]+)`", lambda m: "" + esc(m.group(1)) + "", t) + t = re.sub( + r"\[([^\]]+)\]\((https?://[^\)]+)\)", + lambda m: f'
        {esc(m.group(1))}', + t, + ) + t = re.sub(r"]*>", lambda m: m.group() if SAFE_INLINE.match(m.group()) else esc(m.group()), t) + return t + + +def render_md(raw): + """ + Python mirror of renderMd() in static/ui.js. + Kept in sync with the JS implementation so tests catch regressions + if the JS logic drifts from the documented behaviour. + """ + s = raw or "" + + # Pre-pass: stash code blocks/spans, convert safe HTML → markdown equivalents + fence_stash = [] + + def stash(m): + fence_stash.append(m.group()) + return "\x00F" + str(len(fence_stash) - 1) + "\x00" + + s = re.sub(r"(```[\s\S]*?```|`[^`\n]+`)", stash, s) + s = re.sub(r"([\s\S]*?)", lambda m: "**" + m.group(1) + "**", s, flags=re.I) + s = re.sub(r"([\s\S]*?)", lambda m: "**" + m.group(1) + "**", s, flags=re.I) + s = re.sub(r"([\s\S]*?)", lambda m: "*" + m.group(1) + "*", s, flags=re.I) + s = re.sub(r"([\s\S]*?)", lambda m: "*" + m.group(1) + "*", s, flags=re.I) + s = re.sub(r"([^<]*?)", lambda m: "`" + m.group(1) + "`", s, flags=re.I) + s = re.sub(r"", "\n", s, flags=re.I) + s = re.sub(r"\x00F(\d+)\x00", lambda m: fence_stash[int(m.group(1))], s) + + # Fenced code blocks + def fenced(m): + lang, code = m.group(1), m.group(2).rstrip("\n") + h = f'
        {esc(lang)}
        ' if lang else "" + return h + "
        " + esc(code) + "
        " + s = re.sub(r"```([\w+-]*)\n?([\s\S]*?)```", fenced, s) + s = re.sub(r"`([^`\n]+)`", lambda m: "" + esc(m.group(1)) + "", s) + + # Inline formatting (top-level, outside list items) + s = re.sub(r"\*\*\*(.+?)\*\*\*", lambda m: "" + esc(m.group(1)) + "", s) + s = re.sub(r"\*\*(.+?)\*\*", lambda m: "" + esc(m.group(1)) + "", s) + s = re.sub(r"\*([^*\n]+)\*", lambda m: "" + esc(m.group(1)) + "", s) + + # Block elements using inlineMd for their content + s = re.sub(r"^### (.+)$", lambda m: "

        " + inline_md(m.group(1)) + "

        ", s, flags=re.M) + s = re.sub(r"^## (.+)$", lambda m: "

        " + inline_md(m.group(1)) + "

        ", s, flags=re.M) + s = re.sub(r"^# (.+)$", lambda m: "

        " + inline_md(m.group(1)) + "

        ", s, flags=re.M) + s = re.sub(r"^---+$", "
        ", s, flags=re.M) + s = re.sub(r"^> (.+)$", lambda m: "
        " + inline_md(m.group(1)) + "
        ", s, flags=re.M) + + def handle_ul(block): + lines = block.strip().split("\n") + out = "
          " + for l in lines: + indent = bool(re.match(r"^ {2,}", l)) + text = re.sub(r"^ {0,4}[-*+] ", "", l) + style = ' style="margin-left:16px"' if indent else "" + out += f"{inline_md(text)}" + return out + "
        " + + s = re.sub(r"((?:^(?: )?[-*+] .+\n?)+)", lambda m: handle_ul(m.group()), s, flags=re.M) + + def handle_ol(block): + lines = block.strip().split("\n") + out = "
          " + for l in lines: + text = re.sub(r"^ {0,4}\d+\. ", "", l) + out += f"
        1. {inline_md(text)}
        2. " + return out + "
        " + + s = re.sub(r"((?:^(?: )?\d+\. .+\n?)+)", lambda m: handle_ol(m.group()), s, flags=re.M) + + # Safety net: escape unknown tags in remaining text + s = re.sub(r"]*>", lambda m: m.group() if SAFE_TAGS.match(m.group()) else esc(m.group()), s) + + # Paragraph wrap + parts = s.split("\n\n") + def wrap(p): + p = p.strip() + if not p: return "" + if re.match(r"^<(h[1-6]|ul|ol|pre|hr|blockquote)", p): return p + return "

        " + p.replace("\n", "
        ") + "

        " + s = "\n".join(wrap(p) for p in parts) + return s + + +# ── Static analysis: verify key structures exist in ui.js ──────────────────── + +def test_render_md_pre_pass_converts_strong(cleanup_test_sessions): + """ui.js renderMd() must have pre-pass that converts to **.""" + src = REPO_ROOT / "static" / "ui.js" + code = src.read_text() + assert "" in code and "**" in code, "pre-pass for not found" + # Verify the specific conversion pattern + assert re.search(r".*?\*\*", code, re.S), \ + "renderMd pre-pass should convert ... to **...**" + + +def test_render_md_has_safety_net(cleanup_test_sessions): + """ui.js must have a safety-net that escapes unknown HTML tags after the pipeline.""" + src = REPO_ROOT / "static" / "ui.js" + code = src.read_text() + assert "SAFE_TAGS" in code, "SAFE_TAGS allowlist regex not found in ui.js" + assert "esc(tag)" in code, "safety-net esc(tag) call not found in ui.js" + + +def test_render_md_stashes_code_blocks(cleanup_test_sessions): + """ui.js pre-pass must stash code blocks before replacing safe HTML tags.""" + src = REPO_ROOT / "static" / "ui.js" + code = src.read_text() + assert "fence_stash" in code, "fence_stash not found in renderMd pre-pass" + + +def test_render_md_handles_br_tag(cleanup_test_sessions): + """ui.js must convert
        to newline in pre-pass.""" + src = REPO_ROOT / "static" / "ui.js" + code = src.read_text() + assert re.search(r" handling not found" + + +def test_render_md_no_placeholder_remnants(cleanup_test_sessions): + """Old Unicode placeholder approach (\\uE001-\\uE005) must be gone.""" + src = REPO_ROOT / "static" / "ui.js" + code = src.read_text() + for old_ph in ["\\uE001", "\\uE002", "\\uE003", "\\uE004", "\\uE005"]: + assert old_ph not in code, \ + f"Old placeholder {old_ph} still present — broken implementation not cleaned up" + + +def test_render_md_safe_tag_allowlist_complete(cleanup_test_sessions): + """SAFE_TAGS allowlist must include all tags the pipeline emits.""" + src = REPO_ROOT / "static" / "ui.js" + code = src.read_text() + required = ["strong", "em", "code", "pre", "ul", "ol", "li", + "table", "blockquote", "hr", "br", "a", "div"] + safe_tags_match = re.search(r"SAFE_TAGS\s*=\s*/(.+?)/i", code) + assert safe_tags_match, "SAFE_TAGS regex not found" + pattern = safe_tags_match.group(1) + for tag in required: + assert tag in pattern, f"Tag '{tag}' missing from SAFE_TAGS allowlist" + + +# ── Behavioural: renderMd logic via Python mirror ───────────────────────────── + +def test_render_md_markdown_bold(cleanup_test_sessions): + """**word** markdown renders as word.""" + out = render_md("Hello **world**") + assert "world" in out + + +def test_render_md_html_strong_passthrough(cleanup_test_sessions): + """word in AI output renders as bold.""" + out = render_md("Hello world") + assert "world" in out + + +def test_render_md_html_b_tag(cleanup_test_sessions): + """word renders as word.""" + out = render_md("Hello world") + assert "world" in out + + +def test_render_md_html_em_passthrough(cleanup_test_sessions): + """word renders as italic.""" + out = render_md("Hello world") + assert "world" in out + + +def test_render_md_html_i_tag(cleanup_test_sessions): + """word renders as word.""" + out = render_md("Hello word") + assert "word" in out + + +def test_render_md_html_code_passthrough(cleanup_test_sessions): + """text renders as inline code.""" + out = render_md("use print()") + assert "print()" in out + + +def test_render_md_html_br_becomes_newline(cleanup_test_sessions): + """
        in AI output becomes a newline (rendered as
        inside

        later).""" + out = render_md("line one
        line two") + assert "line one\nline two" in out or "line one
        line two" in out + + +def test_render_md_mixed_markdown_and_html(cleanup_test_sessions): + """Markdown and HTML formatting can coexist in the same response.""" + out = render_md("**markdown** and html") + assert "markdown" in out + assert "html" in out + + +def test_render_md_html_strong_in_list_item(cleanup_test_sessions): + """THE SCREENSHOT BUG: tags inside list items must render as bold, + not as escaped literal text like <strong>.""" + out = render_md( + "- All items get `border-radius: 0 8px 8px 0`\n" + "- Active item uses #e8a030\n" + "- Project items show their color\n" + "- Regular items stay muted" + ) + assert "<strong>" not in out, \ + "Escaped literal found in list output — bold not rendering" + assert "All items" in out + assert "Active item" in out + assert "border-radius: 0 8px 8px 0" in out + assert "#e8a030" in out + + +def test_render_md_exact_screenshot_content(cleanup_test_sessions): + """Exact text from the ui-changes-unrendered-html-tags.png screenshot. + This is the canonical regression test for the inlineMd fix. + All four bullet points must render and as HTML, not literal text.""" + out = render_md( + "- All items now have border-radius: 0 8px 8px 0" + " \u2014 straight left edge everywhere, rounded on the right\n" + "- Active item is now gold/amber (#e8a030)" + " \u2014 same warm gold used in the logo \u2014 instead of blue," + " so it stands out distinctly from everything else\n" + "- Project items still show their project color on the left" + " border, but only when they're not the active item (active always wins with gold)\n" + "- Regular items (no project) still have no left border color" + ) + # None of the safe tags should appear as literal escaped text + assert "<strong>" not in out, \ + "Literal <strong> found — is not rendering as bold" + assert "</strong>" not in out, \ + "Literal </strong> found — closing tag is not rendering" + assert "<code>" not in out, \ + "Literal <code> found — is not rendering as inline code" + # Each item's bold label must render correctly + assert "All items" in out + assert "Active item" in out + assert "Project items" in out + assert "Regular items" in out + # The code spans in items 1 and 2 must render correctly + assert "border-radius: 0 8px 8px 0" in out + assert "#e8a030" in out + # The surrounding prose text must be preserved + assert "straight left edge everywhere" in out + assert "same warm gold used in the logo" in out + assert "active always wins with gold" in out + + +def test_render_md_markdown_bold_in_list_item(cleanup_test_sessions): + """**bold** markdown inside list items must render as .""" + out = render_md("- **First** item\n- **Second** item with `code`") + assert "First" in out + assert "Second" in out + assert "code" in out + + +def test_render_md_html_strong_in_blockquote(cleanup_test_sessions): + """ inside blockquote must render as bold.""" + out = render_md("> Note: pay attention") + assert "<strong>" not in out + assert "Note:" in out + + +def test_render_md_html_strong_in_heading(cleanup_test_sessions): + """ inside a heading must render as bold.""" + out = render_md("## Important Section") + assert "<strong>" not in out + assert "Important" in out + + +def test_render_md_xss_in_list_still_blocked(cleanup_test_sessions): + """XSS attempts in list items must still be escaped.""" + out = render_md("- bad") + assert " ") + assert " must be HTML-escaped.""" + out = render_md("") + assert "") + assert "