diff --git a/frontend/src/hooks/useVisualViewport.ts b/frontend/src/hooks/useVisualViewport.ts
new file mode 100644
index 0000000..03e1cb8
--- /dev/null
+++ b/frontend/src/hooks/useVisualViewport.ts
@@ -0,0 +1,21 @@
+import { useEffect } from "react";
+
+export function useVisualViewportHeight() {
+ useEffect(() => {
+ const update = () => {
+ const height = window.visualViewport?.height ?? window.innerHeight;
+ document.documentElement.style.setProperty("--app-height", `${height}px`);
+ };
+
+ update();
+ window.visualViewport?.addEventListener("resize", update);
+ window.visualViewport?.addEventListener("scroll", update);
+ window.addEventListener("resize", update);
+
+ return () => {
+ window.visualViewport?.removeEventListener("resize", update);
+ window.visualViewport?.removeEventListener("scroll", update);
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 75f1399..97cebee 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -11,9 +11,20 @@
box-sizing: border-box;
}
+html {
+ height: 100%;
+}
+
body {
margin: 0;
- min-height: 100vh;
+ min-height: 100%;
+ height: var(--app-height, 100dvh);
+ overflow: hidden;
+}
+
+#root {
+ height: 100%;
+ overflow: hidden;
}
button,
diff --git a/frontend/src/pages/Chat.css b/frontend/src/pages/Chat.css
index c7d8e39..36bd0bc 100644
--- a/frontend/src/pages/Chat.css
+++ b/frontend/src/pages/Chat.css
@@ -1,7 +1,9 @@
.chat-layout {
display: grid;
grid-template-columns: 280px 1fr;
- height: calc(100vh - 65px);
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
}
.chat-sidebar {
@@ -12,6 +14,7 @@
gap: 1rem;
background: #12151c;
min-height: 0;
+ overflow: hidden;
}
.primary-btn {
@@ -30,6 +33,7 @@
display: flex;
flex-direction: column;
gap: 0.35rem;
+ min-height: 0;
}
.session-list li {
@@ -66,6 +70,13 @@
display: flex;
flex-direction: column;
min-height: 0;
+ height: 100%;
+ overflow: hidden;
+ background: #0f1115;
+}
+
+.chat-mobile-bar {
+ display: none;
}
.chat-empty {
@@ -75,11 +86,21 @@
.messages {
flex: 1;
+ min-height: 0;
overflow-y: auto;
- padding: 1.5rem;
+ overflow-x: hidden;
+ padding: 1rem 1rem 0.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
+ overscroll-behavior: contain;
+ -webkit-overflow-scrolling: touch;
+ touch-action: pan-y;
+}
+
+.messages-bottom-anchor {
+ flex-shrink: 0;
+ height: 1px;
}
.message {
@@ -176,27 +197,33 @@
.chat-input {
display: flex;
gap: 0.75rem;
- padding: 1rem 1.5rem;
+ flex-shrink: 0;
+ padding: 0.75rem 1rem;
+ padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
border-top: 1px solid #2a2f3a;
+ background: #0f1115;
}
.chat-input textarea {
flex: 1;
+ min-width: 0;
resize: none;
border-radius: 10px;
border: 1px solid #2f3748;
background: #12151c;
color: inherit;
padding: 0.75rem 1rem;
+ font-size: 16px;
}
.chat-input button {
align-self: flex-end;
+ flex-shrink: 0;
background: #4f7cff;
color: white;
border: none;
border-radius: 8px;
- padding: 0.65rem 1.2rem;
+ padding: 0.65rem 1rem;
}
.chat-input button:disabled {
@@ -212,4 +239,53 @@
.chat-sidebar {
display: none;
}
+
+ .chat-mobile-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+ padding: 0.55rem 0.75rem;
+ border-bottom: 1px solid #2a2f3a;
+ background: #12151c;
+ }
+
+ .chat-session-select {
+ flex: 1;
+ min-width: 0;
+ border-radius: 8px;
+ border: 1px solid #2f3748;
+ background: #0f1115;
+ color: inherit;
+ padding: 0.5rem 0.65rem;
+ font-size: 16px;
+ }
+
+ .chat-mobile-new {
+ flex-shrink: 0;
+ border: none;
+ border-radius: 8px;
+ background: #4f7cff;
+ color: #fff;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.9rem;
+ }
+
+ .messages {
+ padding: 0.75rem 0.75rem 0.35rem;
+ }
+
+ .message {
+ max-width: 92%;
+ }
+
+ .chat-input {
+ padding: 0.65rem 0.75rem;
+ padding-bottom: max(0.65rem, env(safe-area-inset-bottom));
+ gap: 0.5rem;
+ }
+
+ .chat-input button {
+ padding: 0.65rem 0.85rem;
+ }
}
diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx
index ef7e985..7a264a9 100644
--- a/frontend/src/pages/Chat.tsx
+++ b/frontend/src/pages/Chat.tsx
@@ -1,4 +1,4 @@
-import { FormEvent, useEffect, useRef, useState } from "react";
+import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { api, ChatMessage, ChatSession } from "../api/client";
import PomodoroWidget from "../components/PomodoroWidget";
@@ -42,7 +42,9 @@ export default function Chat() {
"thinking",
);
const [liveNotices, setLiveNotices] = useState
([]);
- const bottomRef = useRef(null);
+ const messagesRef = useRef(null);
+ const inputRef = useRef(null);
+ const scrollRafRef = useRef(null);
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
const [lastNotifySeq, setLastNotifySeq] = useState(0);
@@ -69,9 +71,33 @@ export default function Chat() {
}
}, [activeId]);
+ const scrollToBottom = useCallback((smooth = false) => {
+ const container = messagesRef.current;
+ if (!container) return;
+ container.scrollTo({
+ top: container.scrollHeight,
+ behavior: smooth ? "smooth" : "auto",
+ });
+ }, []);
+
useEffect(() => {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [messages, streaming, liveNotices, loading]);
+ if (scrollRafRef.current !== null) {
+ cancelAnimationFrame(scrollRafRef.current);
+ }
+ scrollRafRef.current = requestAnimationFrame(() => {
+ scrollToBottom(!streaming);
+ scrollRafRef.current = null;
+ });
+ return () => {
+ if (scrollRafRef.current !== null) {
+ cancelAnimationFrame(scrollRafRef.current);
+ }
+ };
+ }, [messages, streaming, liveNotices, loading, scrollToBottom]);
+
+ const dismissKeyboard = useCallback(() => {
+ inputRef.current?.blur();
+ }, []);
const waitingForStream = loading && !streaming;
const pendingLabel =
@@ -119,6 +145,7 @@ export default function Chat() {
const text = input.trim();
setInput("");
+ dismissKeyboard();
setLoading(true);
setStreaming("");
setPendingPhase("thinking");
@@ -215,7 +242,29 @@ export default function Chat() {
Создайте новый чат, чтобы начать
) : (
<>
-
+
+
+
+
+
+
{visibleMessages.map((msg) => (
{roleLabel(msg.role, msg.content)}
@@ -260,15 +309,18 @@ export default function Chat() {
)}
-
+