@liguoshuai/pi-web-chat 1.10.1 → 1.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.10.3] - 2026-08-30
11
+
12
+ ### Fixed & Improved
13
+ - **移动端附件导入功能全面重构与修复 (Mobile Attachment Import Fix)**:
14
+ - **移动端文件选择器唤起修复**:将上传触发器重构为标准可访问的 `<label for="imageFileInput">` 并配合 CSS `.sr-only-file-input`,彻底解决 iOS Safari、Android Chrome、移动端 WebView 及微信内置浏览器中因 `display: none` 导致异步 JS `.click()` 被浏览器安全策略拦截而无法调起系统相册/文件选择器的问题。
15
+ - **移动端格式与相机照片兼容性**:扩展对 iOS HEIC/HEIF 相机原图、AVIF、ICO、SVG 及大写扩展名的识别与支持。
16
+ - **移动端高清原图智能压缩降采样 (Client-side Downscale)**:针对手机拍摄的超高分辨率照片(12MP~48MP),自动使用 Canvas 进行等比下采样(最大 2048px)与高质量编码压缩,防止超大 Base64 数据挤爆移动端内存或造成 WebSocket 通信中断,同时确保所有主流大模型 Vision API 稳定解析。
17
+ - **文本与源码文件附件导入支持 (Text & Code Attachment Parsing)**:支持通过附件按钮、拖拽或剪贴板直接导入 `.py`、`.js`、`.json`、`.md`、`.txt`、`.log` 等各类代码和文本附件,自动提取并按对应语言 Markdown 代码块注入输入框。
18
+ - **预览交互与触控体验优化 (Touch UX & Preview Lightbox)**:输入框待发送图片缩略图支持点击直接弹出 Lightbox 大图预览,加大移动端单张删除按钮的触控热区(22px)与操作反馈。
19
+
20
+ ---
21
+
22
+ ## [1.10.2] - 2026-08-30
23
+
24
+ ### Fixed & Improved
25
+ - **移动端顶栏排版与信息密度重构 (Mobile Topbar UX)**:
26
+ - 在移动端隐藏多余的会话名截断占位,隐藏导出按钮文字标签仅保留操作图标,为工作目录与模型选择胶囊腾出充足显示空间。
27
+ - 优化移动端触控胶囊布局与间距,杜绝文字极端挤压和残缺问题。
28
+ - **欢迎区与模型卡片响应式布局优化 (Empty State & Model Banner)**:
29
+ - 优化欢迎引导文案,兼容移动端抽屉式侧边栏交互。
30
+ - 为 `★ 默认模型` 徽标增加专用样式并强制单行禁止折行,彻底消除断词分行问题。
31
+ - 修复模型卡片在移动端下的左右对齐与视觉比例,“切换模型”按钮平滑对齐。
32
+ - 优化快捷推荐指令标签(Suggestions Chips),支持移动端优雅折行排版。
33
+ - **移动端输入框与占位提示适配 (Composer Mobile Adaptation)**:
34
+ - 移动端自动使用精简的 Placeholder 提示文案,彻底解决长提示在窄屏单行输入框下文字被腰斩截断的问题。
35
+ - 增强底部操作区全面屏手势安全区(`safe-area-inset-bottom`)适配。
36
+
37
+ ---
38
+
10
39
  ## [1.10.1] - 2026-08-30
11
40
 
12
41
  ### Improved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
4
4
  "description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/public/app.js CHANGED
@@ -812,7 +812,11 @@ function renderImagePreviews() {
812
812
  }
813
813
  bar.style.display = "flex";
814
814
  state.attachedImages.forEach((img, idx) => {
815
- const item = el("div", { class: "image-preview-item" }, [
815
+ const item = el("div", {
816
+ class: "image-preview-item",
817
+ title: "点击查看大图",
818
+ onclick: () => openLightbox(img.url)
819
+ }, [
816
820
  el("img", { src: img.url, alt: "预览" }),
817
821
  el("button", {
818
822
  class: "image-preview-remove",
@@ -840,38 +844,173 @@ function detectImageMimeType(file) {
840
844
  bmp: "image/bmp",
841
845
  svg: "image/svg+xml",
842
846
  ico: "image/x-icon",
843
- avif: "image/avif"
847
+ avif: "image/avif",
848
+ heic: "image/heic",
849
+ heif: "image/heif"
844
850
  };
845
851
  return map[ext] || "image/png";
846
852
  }
847
853
 
848
- function handleImageFiles(files) {
849
- if (!files || files.length === 0) return;
850
- const imageFiles = Array.from(files).filter(f => {
851
- return (f.type && f.type.startsWith("image/")) ||
852
- (f.name && /\.(png|jpe?g|webp|gif|bmp|svg|ico|avif)$/i.test(f.name));
853
- });
854
- if (imageFiles.length === 0) return;
855
-
856
- imageFiles.forEach(file => {
854
+ async function processImageFile(file) {
855
+ const mimeType = detectImageMimeType(file);
856
+ const dataUrl = await new Promise((resolve, reject) => {
857
857
  const reader = new FileReader();
858
- reader.onload = () => {
859
- const result = reader.result;
860
- if (typeof result !== "string") return;
861
- const commaIdx = result.indexOf(",");
862
- const base64 = commaIdx !== -1 ? result.slice(commaIdx + 1) : result;
863
- const mimeType = detectImageMimeType(file);
864
- state.attachedImages.push({
865
- data: base64,
866
- mimeType,
867
- url: result
868
- });
869
- renderImagePreviews();
870
- };
858
+ reader.onload = () => resolve(reader.result);
859
+ reader.onerror = reject;
871
860
  reader.readAsDataURL(file);
872
861
  });
862
+
863
+ if (typeof dataUrl !== "string") return null;
864
+
865
+ // For SVG or GIF (which might be animated), or small images, keep original
866
+ if (mimeType.includes("svg") || mimeType.includes("gif") || (file.size && file.size < 800 * 1024)) {
867
+ const commaIdx = dataUrl.indexOf(",");
868
+ const base64 = commaIdx !== -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
869
+ return { data: base64, mimeType, url: dataUrl };
870
+ }
871
+
872
+ // Optimize large phone camera photos / oversized images via canvas downscale
873
+ try {
874
+ const img = await new Promise((resolve, reject) => {
875
+ const i = new Image();
876
+ i.onload = () => resolve(i);
877
+ i.onerror = reject;
878
+ i.src = dataUrl;
879
+ });
880
+
881
+ const maxDim = 2048;
882
+ let { width, height } = img;
883
+ if (width > maxDim || height > maxDim) {
884
+ if (width > height) {
885
+ height = Math.round((height * maxDim) / width);
886
+ width = maxDim;
887
+ } else {
888
+ width = Math.round((width * maxDim) / height);
889
+ height = maxDim;
890
+ }
891
+ }
892
+
893
+ const canvas = document.createElement("canvas");
894
+ canvas.width = width;
895
+ canvas.height = height;
896
+ const ctx = canvas.getContext("2d");
897
+ if (!ctx) throw new Error("Canvas 2D unavailable");
898
+ ctx.drawImage(img, 0, 0, width, height);
899
+
900
+ const targetMime = (mimeType === "image/png" && file.size < 2 * 1024 * 1024) ? "image/png" : "image/jpeg";
901
+ const quality = 0.88;
902
+ const optimizedUrl = canvas.toDataURL(targetMime, quality);
903
+ const commaIdx = optimizedUrl.indexOf(",");
904
+ const base64 = commaIdx !== -1 ? optimizedUrl.slice(commaIdx + 1) : optimizedUrl;
905
+
906
+ return {
907
+ data: base64,
908
+ mimeType: targetMime,
909
+ url: optimizedUrl
910
+ };
911
+ } catch {
912
+ // Fallback to original base64 if canvas processing is unsupported
913
+ const commaIdx = dataUrl.indexOf(",");
914
+ const base64 = commaIdx !== -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
915
+ return { data: base64, mimeType, url: dataUrl };
916
+ }
917
+ }
918
+
919
+ function getLanguageFromFilename(filename) {
920
+ if (!filename) return "";
921
+ const ext = filename.split(".").pop().toLowerCase();
922
+ const langMap = {
923
+ js: "javascript", mjs: "javascript", cjs: "javascript",
924
+ ts: "typescript", tsx: "tsx", jsx: "jsx",
925
+ py: "python", pyw: "python",
926
+ rb: "ruby", rs: "rust", go: "go", java: "java",
927
+ c: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", h: "c", hpp: "cpp",
928
+ sh: "bash", bash: "bash", zsh: "bash",
929
+ json: "json", yaml: "yaml", yml: "yaml", toml: "toml",
930
+ md: "markdown", markdown: "markdown",
931
+ html: "html", htm: "html", css: "css", scss: "scss", less: "less",
932
+ sql: "sql", xml: "xml", svg: "xml",
933
+ log: "log", env: "ini", ini: "ini", conf: "ini",
934
+ diff: "diff", patch: "diff", dockerfile: "dockerfile", makefile: "makefile"
935
+ };
936
+ return langMap[ext] || "";
937
+ }
938
+
939
+ function isTextFile(file) {
940
+ if (file.type && (file.type.startsWith("text/") || file.type.includes("json") || file.type.includes("xml") || file.type.includes("javascript") || file.type.includes("yaml"))) {
941
+ return true;
942
+ }
943
+ const name = file.name ? file.name.toLowerCase() : "";
944
+ return /\.(txt|md|markdown|json|js|mjs|cjs|ts|tsx|jsx|py|pyw|rb|php|java|c|cpp|cc|cxx|h|hpp|rs|go|sh|bash|zsh|sql|html|htm|css|scss|sass|less|vue|svelte|yaml|yml|toml|ini|env|xml|log|csv|tsv|diff|patch|dockerfile|makefile)$/i.test(name);
945
+ }
946
+
947
+ async function handleIncomingFiles(files) {
948
+ if (!files || files.length === 0) return;
949
+ const list = Array.from(files);
950
+
951
+ let imageCount = 0;
952
+ let textCount = 0;
953
+
954
+ for (const file of list) {
955
+ const isImg = (file.type && file.type.startsWith("image/")) ||
956
+ (file.name && /\.(png|jpe?g|webp|gif|bmp|svg|ico|avif|heic|heif)$/i.test(file.name));
957
+
958
+ if (isImg) {
959
+ try {
960
+ const imgObj = await processImageFile(file);
961
+ if (imgObj) {
962
+ state.attachedImages.push(imgObj);
963
+ renderImagePreviews();
964
+ imageCount++;
965
+ }
966
+ } catch (err) {
967
+ console.error("Failed to process image file:", err);
968
+ }
969
+ } else if (isTextFile(file)) {
970
+ if (file.size > 1024 * 1024) {
971
+ showToast(`文件 ${file.name} 较大,建议放入工作目录供 pi 访问`);
972
+ continue;
973
+ }
974
+ try {
975
+ const content = await new Promise((resolve, reject) => {
976
+ const reader = new FileReader();
977
+ reader.onload = () => resolve(reader.result);
978
+ reader.onerror = reject;
979
+ reader.readAsText(file);
980
+ });
981
+ if (typeof content === "string") {
982
+ const lang = getLanguageFromFilename(file.name);
983
+ const composer = $("#composer");
984
+ if (composer) {
985
+ const block = `[附件: ${file.name}]\n\`\`\`${lang}\n${content}\n\`\`\`\n`;
986
+ if (composer.value.trim()) {
987
+ composer.value = composer.value.trimEnd() + "\n\n" + block;
988
+ } else {
989
+ composer.value = block;
990
+ }
991
+ autoResize();
992
+ composer.focus();
993
+ textCount++;
994
+ }
995
+ }
996
+ } catch (err) {
997
+ console.error("Failed to read text file:", err);
998
+ }
999
+ } else {
1000
+ showToast(`暂不支持直接解析该附件格式 (${file.name || "未知类型"})`);
1001
+ }
1002
+ }
1003
+
1004
+ if (imageCount > 0) {
1005
+ showToast(`已添加 ${imageCount} 张图片附件`);
1006
+ }
1007
+ if (textCount > 0) {
1008
+ showToast(`已导入 ${textCount} 个文本文件`);
1009
+ }
873
1010
  }
874
1011
 
1012
+ const handleImageFiles = handleIncomingFiles;
1013
+
875
1014
  function exportCurrentSession() {
876
1015
  const chatInner = $("#chat-inner");
877
1016
  if (!chatInner || chatInner.children.length === 0) {
@@ -2335,6 +2474,21 @@ function baseName(p) {
2335
2474
  }
2336
2475
 
2337
2476
  // ---- Composer ----
2477
+ function getComposerPlaceholder() {
2478
+ const isMobile = window.innerWidth <= 768;
2479
+ if (state.aborting) {
2480
+ return isMobile ? "正在中止…" : "正在中止当前任务…";
2481
+ }
2482
+ if (state.streaming) {
2483
+ return isMobile
2484
+ ? "AI 运行中… 可输入补充指令"
2485
+ : "AI 正在运行中… 可在此输入补充/指导指令,按 Enter 或点击【插入指令】实时调整";
2486
+ }
2487
+ return isMobile
2488
+ ? "给 pi 发消息…"
2489
+ : "给 pi 发消息… (Enter 发送,Shift+Enter 换行)";
2490
+ }
2491
+
2338
2492
  function updateComposerUI() {
2339
2493
  const ta = $("#composer");
2340
2494
  const text = ta ? ta.value.trim() : "";
@@ -2356,7 +2510,7 @@ function updateComposerUI() {
2356
2510
  if (steerBtn) {
2357
2511
  steerBtn.style.display = "none";
2358
2512
  }
2359
- if (ta) ta.placeholder = "正在中止当前任务…";
2513
+ if (ta) ta.placeholder = getComposerPlaceholder();
2360
2514
  } else if (state.streaming) {
2361
2515
  if (sendBtn) {
2362
2516
  sendBtn.classList.add("stop");
@@ -2372,7 +2526,7 @@ function updateComposerUI() {
2372
2526
  steerBtn.style.display = "none";
2373
2527
  }
2374
2528
  }
2375
- if (ta) ta.placeholder = "AI 正在运行中… 可在此输入补充/指导指令,按 Enter 或点击【插入指令】实时调整";
2529
+ if (ta) ta.placeholder = getComposerPlaceholder();
2376
2530
  } else {
2377
2531
  if (sendBtn) {
2378
2532
  sendBtn.classList.remove("stop");
@@ -2383,7 +2537,7 @@ function updateComposerUI() {
2383
2537
  if (steerBtn) {
2384
2538
  steerBtn.style.display = "none";
2385
2539
  }
2386
- if (ta) ta.placeholder = "给 pi 发消息… (Enter 发送,Shift+Enter 换行)";
2540
+ if (ta) ta.placeholder = getComposerPlaceholder();
2387
2541
  }
2388
2542
  }
2389
2543
 
@@ -2699,30 +2853,43 @@ async function init() {
2699
2853
  exportBtn.addEventListener("click", exportCurrentSession);
2700
2854
  }
2701
2855
 
2702
- // Image attach / file picker / paste / drag-and-drop
2856
+ // Image and file attach / picker / paste / drag-and-drop
2703
2857
  const btnAttach = $("#btnAttachImage");
2704
2858
  const fileInput = $("#imageFileInput");
2705
- if (btnAttach && fileInput) {
2706
- btnAttach.addEventListener("click", () => fileInput.click());
2859
+ if (fileInput) {
2707
2860
  fileInput.addEventListener("change", (e) => {
2708
- handleImageFiles(e.target.files);
2861
+ handleIncomingFiles(e.target.files);
2709
2862
  fileInput.value = "";
2710
2863
  });
2711
2864
  }
2712
2865
 
2713
- // Image paste support
2866
+ if (btnAttach) {
2867
+ // If not a label (e.g. fallback button), click input
2868
+ if (btnAttach.tagName !== "LABEL") {
2869
+ btnAttach.addEventListener("click", () => fileInput?.click());
2870
+ }
2871
+ // Keyboard accessibility for Enter / Space
2872
+ btnAttach.addEventListener("keydown", (e) => {
2873
+ if (e.key === "Enter" || e.key === " ") {
2874
+ e.preventDefault();
2875
+ fileInput?.click();
2876
+ }
2877
+ });
2878
+ }
2879
+
2880
+ // Image and file paste support
2714
2881
  document.addEventListener("paste", (e) => {
2715
2882
  const items = e.clipboardData?.items;
2716
2883
  if (!items) return;
2717
2884
  const files = [];
2718
2885
  for (let i = 0; i < items.length; i++) {
2719
- if (items[i].type.indexOf("image") !== -1) {
2886
+ if (items[i].kind === "file") {
2720
2887
  const file = items[i].getAsFile();
2721
2888
  if (file) files.push(file);
2722
2889
  }
2723
2890
  }
2724
2891
  if (files.length > 0) {
2725
- handleImageFiles(files);
2892
+ handleIncomingFiles(files);
2726
2893
  const ta = $("#composer");
2727
2894
  if (ta) ta.focus();
2728
2895
  }
@@ -2732,7 +2899,7 @@ async function init() {
2732
2899
  window.addEventListener("dragover", (e) => e.preventDefault(), false);
2733
2900
  window.addEventListener("drop", (e) => e.preventDefault(), false);
2734
2901
 
2735
- // Drag and drop images to composer
2902
+ // Drag and drop images and files to composer
2736
2903
  const composerBox = $("#composerInner") || $(".composer");
2737
2904
  if (composerBox) {
2738
2905
  composerBox.addEventListener("dragover", (e) => {
@@ -2750,7 +2917,7 @@ async function init() {
2750
2917
  e.stopPropagation();
2751
2918
  composerBox.classList.remove("drag-over");
2752
2919
  if (e.dataTransfer?.files) {
2753
- handleImageFiles(e.dataTransfer.files);
2920
+ handleIncomingFiles(e.dataTransfer.files);
2754
2921
  }
2755
2922
  });
2756
2923
  }
@@ -2824,6 +2991,7 @@ async function init() {
2824
2991
  lastIsMobile = isMobile;
2825
2992
  // Crossing the breakpoint: always close the drawer to reach a known state.
2826
2993
  $(".app").classList.remove("sidebar-open");
2994
+ updateComposerUI();
2827
2995
  }
2828
2996
  });
2829
2997
 
package/public/index.html CHANGED
@@ -79,7 +79,7 @@
79
79
  <div id="emptyState" class="empty-state" style="display:flex;">
80
80
  <div class="logo">π</div>
81
81
  <h1>有什么可以帮你?</h1>
82
- <p>我是 <strong>pi</strong> 编程助手,可以读写文件、运行命令、改代码。在下面输入你的需求,或选择左侧的历史会话继续。</p>
82
+ <p>我是 <strong>pi</strong> 编程助手,可以读写文件、运行命令、改代码。在下方输入你的需求开始,或打开历史会话继续。</p>
83
83
  <div class="empty-model-banner" id="emptyModelBanner">
84
84
  <div class="empty-model-left">
85
85
  <span class="empty-model-icon">🤖</span>
@@ -106,13 +106,13 @@
106
106
  <div class="composer">
107
107
  <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
108
108
  <div class="composer-inner" id="composerInner">
109
- <input type="file" id="imageFileInput" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg" multiple style="display:none;">
110
- <button class="attach-btn" id="btnAttachImage" type="button" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
109
+ <input type="file" id="imageFileInput" class="sr-only-file-input" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.heic,.heif,.ico,.avif,text/*,.txt,.md,.json,.js,.ts,.py,.sh,.html,.css,.yaml,.yml,.toml,.xml,.sql,.log,.csv" multiple>
110
+ <label class="attach-btn" id="btnAttachImage" for="imageFileInput" role="button" tabindex="0" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
111
111
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
112
112
  <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
113
113
  </svg>
114
- </button>
115
- <textarea id="composer" rows="1" placeholder="给 pi 发消息… (Enter 发送,Shift+Enter 换行,支持粘贴/拖拽图片)"></textarea>
114
+ </label>
115
+ <textarea id="composer" rows="1" placeholder="给 pi 发消息…"></textarea>
116
116
  <button class="steer-btn" id="steerBtn" title="在 AI 运行过程中插入指导指令" style="display:none;">
117
117
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg>
118
118
  <span>插入指令</span>
package/public/style.css CHANGED
@@ -812,6 +812,7 @@ body {
812
812
  gap: 6px;
813
813
  font-size: 13px;
814
814
  color: var(--text);
815
+ flex-wrap: wrap;
815
816
  }
816
817
  .empty-model-label {
817
818
  color: var(--text-muted);
@@ -824,6 +825,20 @@ body {
824
825
  text-overflow: ellipsis;
825
826
  white-space: nowrap;
826
827
  }
828
+ .badge-default {
829
+ display: inline-flex;
830
+ align-items: center;
831
+ font-size: 11px;
832
+ font-weight: 500;
833
+ color: #f59e0b;
834
+ background: rgba(245, 158, 11, 0.12);
835
+ border: 1px solid rgba(245, 158, 11, 0.25);
836
+ border-radius: 4px;
837
+ padding: 1px 6px;
838
+ white-space: nowrap !important;
839
+ flex-shrink: 0;
840
+ line-height: 1.3;
841
+ }
827
842
  .btn-topbar-action {
828
843
  display: inline-flex;
829
844
  align-items: center;
@@ -880,6 +895,20 @@ body {
880
895
  .suggestions .chip:hover { border-color: var(--text-dim); color: var(--text); }
881
896
 
882
897
  /* ---------- Composer & Image Upload ---------- */
898
+ .sr-only-file-input {
899
+ position: absolute !important;
900
+ width: 1px !important;
901
+ height: 1px !important;
902
+ padding: 0 !important;
903
+ margin: -1px !important;
904
+ overflow: hidden !important;
905
+ clip: rect(0, 0, 0, 0) !important;
906
+ white-space: nowrap !important;
907
+ border: 0 !important;
908
+ opacity: 0 !important;
909
+ pointer-events: none !important;
910
+ }
911
+
883
912
  .composer {
884
913
  padding: 12px 24px 20px;
885
914
  background: var(--bg);
@@ -902,6 +931,12 @@ body {
902
931
  background: #111;
903
932
  flex-shrink: 0;
904
933
  box-shadow: 0 2px 6px rgba(0,0,0,0.3);
934
+ cursor: pointer;
935
+ transition: transform 0.15s ease, border-color 0.15s ease;
936
+ }
937
+ .image-preview-item:hover {
938
+ transform: translateY(-2px);
939
+ border-color: var(--accent);
905
940
  }
906
941
  .image-preview-item img {
907
942
  width: 100%;
@@ -926,10 +961,12 @@ body {
926
961
  font-size: 12px;
927
962
  line-height: 1;
928
963
  padding: 0;
929
- transition: background 0.15s;
964
+ transition: background 0.15s, transform 0.1s;
965
+ z-index: 2;
930
966
  }
931
967
  .image-preview-remove:hover {
932
968
  background: var(--danger, #ef4444);
969
+ transform: scale(1.1);
933
970
  }
934
971
 
935
972
  .composer-inner {
@@ -957,12 +994,15 @@ body {
957
994
  border: none;
958
995
  color: var(--text-muted);
959
996
  cursor: pointer;
960
- display: flex;
997
+ display: inline-flex;
961
998
  align-items: center;
962
999
  justify-content: center;
963
1000
  border-radius: 50%;
964
1001
  transition: color 0.15s ease, background 0.15s ease, transform 0.1s ease;
965
1002
  flex-shrink: 0;
1003
+ -webkit-tap-highlight-color: transparent;
1004
+ touch-action: manipulation;
1005
+ user-select: none;
966
1006
  }
967
1007
  .composer .attach-btn:hover {
968
1008
  color: var(--text);
@@ -1504,7 +1544,7 @@ body {
1504
1544
 
1505
1545
  /* Topbar Mobile Adjustments — fixed, taller, shadowed so it is always findable */
1506
1546
  .topbar {
1507
- height: 52px;
1547
+ height: 50px;
1508
1548
  padding: 0 8px;
1509
1549
  gap: 6px;
1510
1550
  position: fixed;
@@ -1515,21 +1555,16 @@ body {
1515
1555
  background: var(--bg);
1516
1556
  border-bottom: 1px solid var(--border);
1517
1557
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
1558
+ display: flex;
1559
+ align-items: center;
1518
1560
  }
1519
1561
 
1520
1562
  .main {
1521
- padding-top: 52px;
1563
+ padding-top: 50px;
1522
1564
  }
1523
1565
 
1524
1566
  .topbar .session-name {
1525
- font-size: 13px;
1526
- font-weight: 500;
1527
- min-width: 0;
1528
- flex: 1;
1529
- overflow: hidden;
1530
- text-overflow: ellipsis;
1531
- white-space: nowrap;
1532
- padding: 0 2px;
1567
+ display: none !important;
1533
1568
  }
1534
1569
 
1535
1570
  .btn-toggle-sidebar {
@@ -1548,7 +1583,7 @@ body {
1548
1583
  #cwdPillWrap {
1549
1584
  padding: 4px 7px;
1550
1585
  font-size: 11px;
1551
- max-width: 75px;
1586
+ max-width: 80px;
1552
1587
  overflow: hidden;
1553
1588
  text-overflow: ellipsis;
1554
1589
  white-space: nowrap;
@@ -1559,22 +1594,26 @@ body {
1559
1594
  display: flex;
1560
1595
  align-items: center;
1561
1596
  gap: 4px;
1562
- flex-shrink: 0;
1563
- max-width: none;
1597
+ flex: 1;
1598
+ min-width: 0;
1564
1599
  }
1565
1600
 
1566
1601
  .model-pill {
1567
1602
  padding: 4px 8px;
1568
1603
  font-size: 11px;
1569
- max-width: 105px;
1604
+ max-width: 125px;
1570
1605
  overflow: hidden;
1571
1606
  text-overflow: ellipsis;
1572
1607
  white-space: nowrap;
1573
- flex-shrink: 0;
1608
+ flex-shrink: 1;
1609
+ min-width: 0;
1574
1610
  gap: 4px;
1575
1611
  }
1576
1612
  .model-pill-name {
1577
- max-width: 65px;
1613
+ max-width: 100%;
1614
+ overflow: hidden;
1615
+ text-overflow: ellipsis;
1616
+ white-space: nowrap;
1578
1617
  }
1579
1618
  .model-pill-badge {
1580
1619
  display: none !important;
@@ -1597,20 +1636,30 @@ body {
1597
1636
  font-weight: 500;
1598
1637
  }
1599
1638
 
1639
+ .btn-topbar-action {
1640
+ width: 32px;
1641
+ height: 32px;
1642
+ padding: 0;
1643
+ display: inline-flex;
1644
+ align-items: center;
1645
+ justify-content: center;
1646
+ flex-shrink: 0;
1647
+ }
1648
+ .topbar-action-label {
1649
+ display: none !important;
1650
+ }
1651
+
1600
1652
  @media (max-width: 360px) {
1601
1653
  .topbar {
1602
1654
  padding: 0 4px;
1603
1655
  gap: 4px;
1604
1656
  }
1605
1657
  #cwdPillWrap {
1606
- max-width: 50px;
1658
+ max-width: 60px;
1607
1659
  padding: 4px 5px;
1608
1660
  }
1609
1661
  .model-pill {
1610
- max-width: 90px;
1611
- }
1612
- .model-pill-name {
1613
- max-width: 50px;
1662
+ max-width: 95px;
1614
1663
  }
1615
1664
  .thinking-pill {
1616
1665
  padding: 4px 5px;
@@ -1637,13 +1686,48 @@ body {
1637
1686
  }
1638
1687
 
1639
1688
  .empty-model-banner {
1640
- padding: 10px 12px;
1641
- flex-direction: column;
1689
+ padding: 12px 14px;
1690
+ flex-direction: row;
1691
+ align-items: center;
1692
+ justify-content: space-between;
1693
+ gap: 10px;
1694
+ width: 100%;
1695
+ max-width: 100%;
1696
+ box-sizing: border-box;
1697
+ }
1698
+ .empty-model-left {
1699
+ display: flex;
1642
1700
  align-items: flex-start;
1643
- gap: 8px;
1701
+ gap: 10px;
1702
+ flex: 1;
1703
+ min-width: 0;
1704
+ }
1705
+ .empty-model-icon {
1706
+ font-size: 22px;
1707
+ margin-top: 1px;
1708
+ flex-shrink: 0;
1709
+ }
1710
+ .empty-model-info {
1711
+ flex: 1;
1712
+ min-width: 0;
1713
+ gap: 4px;
1714
+ }
1715
+ .empty-model-title-row {
1716
+ flex-wrap: wrap;
1717
+ gap: 4px 6px;
1718
+ font-size: 12.5px;
1719
+ line-height: 1.4;
1720
+ }
1721
+ .empty-model-name {
1722
+ font-weight: 600;
1723
+ word-break: break-all;
1724
+ white-space: normal;
1644
1725
  }
1645
1726
  .btn-change-model {
1646
- align-self: flex-end;
1727
+ align-self: center;
1728
+ padding: 6px 10px;
1729
+ font-size: 11.5px;
1730
+ flex-shrink: 0;
1647
1731
  }
1648
1732
 
1649
1733
  .mobile-toolbar-fab {
@@ -1691,7 +1775,7 @@ body {
1691
1775
 
1692
1776
  /* Empty state mobile adjustments */
1693
1777
  .empty-state {
1694
- padding: 24px 16px;
1778
+ padding: 20px 14px;
1695
1779
  gap: 12px;
1696
1780
  }
1697
1781
 
@@ -1709,31 +1793,69 @@ body {
1709
1793
  }
1710
1794
 
1711
1795
  .suggestions {
1796
+ display: flex;
1712
1797
  gap: 8px;
1713
- margin-top: 8px;
1798
+ margin-top: 10px;
1799
+ flex-wrap: wrap;
1800
+ justify-content: center;
1801
+ width: 100%;
1802
+ max-width: 100%;
1803
+ box-sizing: border-box;
1714
1804
  }
1715
1805
 
1716
1806
  .suggestions .chip {
1717
- padding: 7px 12px;
1807
+ padding: 8px 12px;
1718
1808
  font-size: 12px;
1809
+ line-height: 1.35;
1810
+ text-align: center;
1811
+ border-radius: 18px;
1812
+ max-width: 100%;
1813
+ box-sizing: border-box;
1814
+ white-space: normal;
1719
1815
  }
1720
1816
 
1721
1817
  /* Composer input mobile adjustments */
1722
1818
  .composer {
1723
- padding: 8px 12px 12px;
1819
+ padding: 8px 12px;
1820
+ padding-bottom: max(12px, calc(8px + env(safe-area-inset-bottom, 0px)));
1821
+ }
1822
+
1823
+ .image-preview-bar {
1824
+ gap: 8px;
1825
+ margin-bottom: 6px;
1826
+ padding: 2px 4px;
1827
+ }
1828
+
1829
+ .image-preview-item {
1830
+ width: 60px;
1831
+ height: 60px;
1832
+ }
1833
+
1834
+ .image-preview-remove {
1835
+ width: 22px;
1836
+ height: 22px;
1837
+ font-size: 14px;
1838
+ top: 2px;
1839
+ right: 2px;
1840
+ background: rgba(0, 0, 0, 0.85);
1724
1841
  }
1725
1842
 
1726
1843
  .composer-inner {
1727
- padding: 6px 8px 6px 14px;
1844
+ padding: 5px 8px 5px 8px;
1728
1845
  border-radius: 22px;
1729
1846
  gap: 6px;
1730
1847
  }
1731
1848
 
1849
+ .composer .attach-btn {
1850
+ width: 32px;
1851
+ height: 32px;
1852
+ }
1853
+
1732
1854
  .composer textarea {
1733
1855
  padding: 6px 0;
1734
- font-size: 14px;
1856
+ font-size: 15px;
1735
1857
  line-height: 20px;
1736
- min-height: 32px;
1858
+ min-height: 28px;
1737
1859
  max-height: 160px;
1738
1860
  }
1739
1861