Ver código fonte

Add mobile responsive design with off-canvas sidebar (#250)

* Make AI Chat web app mobile-friendly (RWD)

The AIChat web app used a fixed two-column grid (268px sidebar + content)
with no media queries, so on phones the sidebar ate most of the screen and
the topbar/composer didn't adapt.

Add a responsive layout for narrow screens (<=768px):
- Off-canvas sidebar that slides in over a backdrop, toggled by a new
  hamburger button in the topbar; auto-closes on chat select/new chat and
  when opening Settings.
- Single-column grid; tighter gutters for messages/composer/topbar; a
  flexible model picker (hidden label + spacer so the select can grow).
- Touch affordances: delete-chat and copy-code buttons always visible
  (no hover on touch) and 40px tap targets.
- 100dvh, interactive-widget=resizes-content and a 16px composer font so
  the composer stays visible above mobile browser chrome/keyboard and iOS
  doesn't zoom on focus.

Desktop layout is unchanged: all new rules are scoped to the media query
or hidden by default.

https://claude.ai/code/session_01M5CKy2tfgt8KeRU9boH7V1

* Confirm before deleting a chat in AI Chat web app

The delete (✕) button on a chat removed it immediately. Prompt for
confirmation first, naming the chat and mirroring the existing
"This cannot be undone." wording used by "Delete all conversations".

https://claude.ai/code/session_01M5CKy2tfgt8KeRU9boH7V1

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 6 dias atrás
pai
commit
8c4f14b160
1 arquivos alterados com 62 adições e 6 exclusões
  1. 62 6
      src/web/AIChat/index.html

+ 62 - 6
src/web/AIChat/index.html

@@ -3,7 +3,7 @@
 <head>
 <meta charset="UTF-8">
 <title>AI Chat</title>
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
 <script src="../script/jquery.min.js"></script>
 <script src="../script/ao_module.js"></script>
 <style>
@@ -29,7 +29,7 @@
         background:var(--bg); color:var(--text); overflow:hidden;
         -webkit-font-smoothing:antialiased;
     }
-    .app{display:grid; grid-template-columns:268px 1fr; height:100vh;}
+    .app{display:grid; grid-template-columns:268px 1fr; height:100vh; height:100dvh;}
 
     /* ── Sidebar ───────────────────────────── */
     .sidebar{
@@ -211,12 +211,52 @@
     .danger-btn:hover{background:rgba(255,93,108,.08);}
     .messages::-webkit-scrollbar,.chat-list::-webkit-scrollbar,.drawer-body::-webkit-scrollbar,.codeblock pre::-webkit-scrollbar{width:9px;height:9px;}
     .messages::-webkit-scrollbar-thumb,.chat-list::-webkit-scrollbar-thumb,.drawer-body::-webkit-scrollbar-thumb{background:var(--border);border-radius:6px;}
+
+    /* ── Mobile / responsive ───────────────────── */
+    .menu-btn{display:none;}              /* hamburger — only shown on narrow screens */
+    .sidebar-scrim{display:none;}         /* backdrop behind the off-canvas sidebar */
+    @media (max-width:768px){
+        /* Single column; the sidebar floats above the content instead */
+        .app{grid-template-columns:1fr;}
+        .sidebar{
+            position:fixed; top:0; left:0; z-index:40;
+            height:100vh; height:100dvh;
+            width:82vw; max-width:300px;
+            transform:translateX(-100%); transition:transform .22s ease;
+        }
+        .sidebar.open{transform:translateX(0);}
+        .sidebar-scrim{
+            display:block; position:fixed; inset:0; z-index:39; background:rgba(0,0,0,.5);
+            opacity:0; pointer-events:none; transition:opacity .2s;
+        }
+        .sidebar-scrim.open{opacity:1; pointer-events:auto;}
+        .menu-btn{display:inline-flex;}
+        .icon-btn{width:40px; height:40px;}   /* roomier tap targets on touch */
+        /* Top bar: drop the static label/spacer so the model picker can grow */
+        .topbar{padding:0 10px; gap:8px;}
+        .topbar .spacer{display:none;}
+        .model-pick{flex:1; min-width:0;}
+        .model-pick .lab{display:none;}
+        .model-input{flex:1 1 auto; min-width:0; max-width:none;}
+        .tok-badge{flex:none;}
+        /* Tighter content gutters */
+        .mwrap{padding:0 14px;}
+        .msg{gap:10px;}
+        .composer{padding:0 12px;}
+        #composer{font-size:16px;}            /* 16px keeps iOS from zooming on focus */
+        .attachments{padding:0 12px;}
+        .composer-wrap{padding:10px 0 12px;}
+        .hint{padding:0 12px;}
+        /* Touch devices have no hover — keep these affordances visible */
+        .chat-item .del{opacity:1;}
+        .copy-code{opacity:1;}
+    }
 </style>
 </head>
 <body>
 <div class="app">
     <!-- Sidebar -->
-    <aside class="sidebar">
+    <aside class="sidebar" id="sidebar">
         <div class="sb-head">
             <img src="img/icon.svg" alt="">
             <div class="name">AI Chat<small>OpenAI-compatible</small></div>
@@ -242,6 +282,9 @@
     <!-- Main -->
     <section class="main">
         <div class="topbar">
+            <button class="icon-btn menu-btn" title="Conversations" onclick="toggleSidebar()">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
+            </button>
             <div class="model-pick">
                 <span class="lab">Model</span>
                 <select class="model-input" id="modelSelect" onchange="onModelChange()"></select>
@@ -312,6 +355,9 @@
             </div>
         </div>
     </section>
+
+    <!-- Backdrop for the mobile off-canvas sidebar -->
+    <div class="sidebar-scrim" id="sidebarScrim" onclick="closeSidebar()"></div>
 </div>
 
 <script>
@@ -422,11 +468,14 @@ function setConn(ok, text){
 function newChat(){
     var c = { id: uid(), title:"New Chat", titleSet:false, messages:[] };
     state.conversations.unshift(c); state.currentId = c.id; persist();
-    renderSidebar(); renderMessages(); $("#composer").focus();
+    renderSidebar(); renderMessages(); closeSidebar(); $("#composer").focus();
 }
-function selectChat(id){ if(generating) stopGenerating(); state.currentId = id; persist(); renderSidebar(); renderMessages(); }
+function selectChat(id){ if(generating) stopGenerating(); state.currentId = id; persist(); renderSidebar(); renderMessages(); closeSidebar(); }
 function deleteChat(id, ev){
     if(ev) ev.stopPropagation();
+    var conv = state.conversations.find(function(c){ return c.id === id; });
+    var name = (conv && conv.title) ? conv.title : "this chat";
+    if(!confirm('Delete "' + name + '"? This cannot be undone.')) return;
     state.conversations = state.conversations.filter(function(c){ return c.id !== id; });
     if(state.currentId === id){ state.currentId = state.conversations[0] ? state.conversations[0].id : null; }
     if(state.conversations.length === 0) return newChat();
@@ -727,7 +776,7 @@ function updateTokenBadge(){
 /* ─────────────────────────────────────────────────────────────
    Settings drawer
    ───────────────────────────────────────────────────────────── */
-function openDrawer(){ syncSettingsUI(); $("#scrim,#drawer").addClass("open"); }
+function openDrawer(){ closeSidebar(); syncSettingsUI(); $("#scrim,#drawer").addClass("open"); }
 function closeDrawer(){ $("#scrim,#drawer").removeClass("open"); }
 function syncSettingsUI(){
     var s = state.settings;
@@ -747,6 +796,13 @@ $(document).on("change", "#setTheme", function(){ applyTheme(this.value); state.
 function toggleTheme(){ var t = (state.settings.theme === "dark") ? "light" : "dark"; applyTheme(t); state.settings.theme = t; persist(); syncSettingsUI(); }
 function applyTheme(t){ document.documentElement.setAttribute("data-theme", t === "light" ? "light" : "dark"); }
 
+/* ─────────────────────────────────────────────────────────────
+   Mobile sidebar (off-canvas) — no-op on desktop where the
+   sidebar is a static grid column.
+   ───────────────────────────────────────────────────────────── */
+function toggleSidebar(){ $("#sidebar,#sidebarScrim").toggleClass("open"); }
+function closeSidebar(){ $("#sidebar,#sidebarScrim").removeClass("open"); }
+
 /* ─────────────────────────────────────────────────────────────
    Markdown rendering (safe: escape first)
    ───────────────────────────────────────────────────────────── */