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
3 changes: 3 additions & 0 deletions desktop/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
<meta name="apple-mobile-web-app-title" content="taOS talk" />
<link rel="apple-touch-icon" href="/static/icon-180.png" />

<!-- iOS splash screens (reuse 512 icon on #1a1b2e background; iOS scales it) -->
<link rel="apple-touch-startup-image" href="/static/icon-512.png" />

<meta name="mobile-web-app-capable" content="yes" />

<style>
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/ChatStandalone.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Suspense, lazy } from "react";
import { InstallPromptBanner } from "./shell/InstallPromptBanner";

const MessagesApp = lazy(() => import("./apps/MessagesApp").then((m) => ({ default: m.MessagesApp })));

Expand All @@ -8,6 +9,7 @@ export function ChatStandalone() {
className="h-screen w-screen flex flex-col overflow-hidden"
style={{ backgroundColor: "#1a1b2e", paddingTop: "env(safe-area-inset-top, 0px)" }}
>
<InstallPromptBanner />
<Suspense fallback={
<div className="flex items-center justify-center h-full" style={{ color: "rgba(255,255,255,0.4)" }}>
Loading…
Expand Down
50 changes: 36 additions & 14 deletions desktop/src/apps/MessagesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "@/components/ui";
import { MobileSplitView } from "@/components/mobile/MobileSplitView";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
import { resolveAgentEmoji } from "@/lib/agent-emoji";
import { ChannelSettingsPanel } from "./chat/ChannelSettingsPanel";
import { AgentContextMenu } from "./chat/AgentContextMenu";
Expand All @@ -44,6 +45,7 @@ import { uploadDiskFile, attachmentFromPath, type AttachmentRecord } from "@/lib
import { useThreadPanel } from "@/lib/use-thread-panel";
import { openFilePicker } from "@/shell/file-picker-api";
import { MessageOverflowMenu } from "./chat/MessageOverflowMenu";
import { BottomSheet } from "@/shell/BottomSheet";
import { MessageEditor } from "./chat/MessageEditor";
import { MessageTombstone } from "./chat/MessageTombstone";
import { PinBadge } from "./chat/PinBadge";
Expand Down Expand Up @@ -204,6 +206,7 @@ const EMOJI_PICKER = ["👍", "❤️", "😂", "🎉", "🤔", "👀", "🚀",

export function MessagesApp({ windowId: _windowId, title }: { windowId: string; title?: string }) {
const isMobile = useIsMobile();
const { keyboardInset } = useVisualViewport();

const [channels, setChannels] = useState<Channel[]>([]);
const [archivedChannels, setArchivedChannels] = useState<Channel[]>([]);
Expand Down Expand Up @@ -1295,6 +1298,7 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
ref={messageListRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5"
style={isMobile && keyboardInset > 0 ? { paddingBottom: `${keyboardInset + 60}px` } : undefined}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
Expand Down Expand Up @@ -1456,8 +1460,8 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
</div>
)}

{/* hover actions */}
{hoveredMessageId === msg.id && (
{/* hover actions — always visible on mobile (no hover available), hover-gated on desktop */}
{(isMobile || hoveredMessageId === msg.id) && (
<div className="absolute top-0 right-2 -translate-y-1/2 z-10">
<MessageHoverActions
onReact={() => setShowEmoji(showEmoji === msg.id ? null : msg.id)}
Expand Down Expand Up @@ -1550,7 +1554,14 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
/>

{/* input area */}
<div className="px-4 py-3 border-t border-white/[0.06] shrink-0">
<div
className="px-4 py-3 border-t border-white/[0.06] shrink-0"
style={
isMobile
? { paddingBottom: `max(env(safe-area-inset-bottom), ${keyboardInset}px)` }
: undefined
}
>
<div className="relative">
{showSlash && (
<SlashMenu
Expand Down Expand Up @@ -1684,21 +1695,31 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
{overflowMenu && (() => {
const msg = messages.find((m) => m.id === overflowMenu.messageId);
if (!msg) return null;
const menu = (
<MessageOverflowMenu
isOwn={msg.author_id === currentUserId}
isHuman={true} /* desktop UI viewer is always human */
isPinned={pinnedMessages.some((p) => p.id === msg.id)}
onEdit={() => handleEdit(msg.id)}
onDelete={() => handleDelete(msg.id)}
onCopyLink={() => handleCopyLink(msg.id)}
onPin={() => handlePin(msg)}
onMarkUnread={() => handleMarkUnread(msg.id)}
onClose={() => setOverflowMenu(null)}
/>
);
if (isMobile) {
return (
<BottomSheet open={true} onClose={() => setOverflowMenu(null)}>
{menu}
</BottomSheet>
);
}
return (
<>
<div className="fixed inset-0 z-40" onClick={() => setOverflowMenu(null)} />
<div className="fixed z-50" style={{ top: overflowMenu.y, left: overflowMenu.x }}>
<MessageOverflowMenu
isOwn={msg.author_id === currentUserId}
isHuman={true} /* desktop UI viewer is always human */
isPinned={pinnedMessages.some((p) => p.id === msg.id)}
onEdit={() => handleEdit(msg.id)}
onDelete={() => handleDelete(msg.id)}
onCopyLink={() => handleCopyLink(msg.id)}
onPin={() => handlePin(msg)}
onMarkUnread={() => handleMarkUnread(msg.id)}
onClose={() => setOverflowMenu(null)}
/>
{menu}
</div>
</>
);
Expand Down Expand Up @@ -1727,6 +1748,7 @@ export function MessagesApp({ windowId: _windowId, title }: { windowId: string;
channelId={openThread.channelId}
parentId={openThread.parentId}
onClose={closeThread}
isFullscreen={isMobile}
onSend={async (content, attachments) => {
const r = await fetch("/api/chat/messages", {
method: "POST",
Expand Down
13 changes: 10 additions & 3 deletions desktop/src/apps/chat/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ export function ThreadPanel({
parentId,
onClose,
onSend,
isFullscreen = false,
}: {
channelId: string;
parentId: string;
onClose: () => void;
onSend: (content: string, attachments: AttachmentRecord[]) => Promise<void>;
isFullscreen?: boolean;
}) {
const [parent, setParent] = useState<Msg | null>(null);
const [msgs, setMsgs] = useState<Msg[]>([]);
Expand Down Expand Up @@ -80,17 +82,22 @@ export function ThreadPanel({

return (
<div
className="fixed top-0 right-0 h-full w-[360px] bg-shell-surface border-l border-white/10 flex flex-col z-40"
className={
isFullscreen
? "fixed inset-0 z-50 bg-shell-surface flex flex-col"
: "fixed top-0 right-0 h-full w-[360px] bg-shell-surface border-l border-white/10 flex flex-col z-40"
}
role="complementary"
aria-label="Thread panel"
style={isFullscreen ? { paddingTop: "env(safe-area-inset-top, 0px)" } : undefined}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<span className="font-semibold text-sm">Thread</span>
<button
aria-label="Close thread"
aria-label={isFullscreen ? "Back" : "Close thread"}
onClick={onClose}
className="p-1 hover:bg-white/5 rounded"
></button>
>{isFullscreen ? "◀" : "✕"}</button>
</div>

<div className="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3">
Expand Down
52 changes: 52 additions & 0 deletions desktop/src/hooks/__tests__/use-visual-viewport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useVisualViewport } from "../use-visual-viewport";

describe("useVisualViewport", () => {
const listeners = new Map<string, Set<() => void>>();
let vv: { height: number; offsetTop: number; addEventListener: (t: string, l: () => void) => void; removeEventListener: (t: string, l: () => void) => void };

beforeEach(() => {
listeners.clear();
vv = {
height: 800,
offsetTop: 0,
addEventListener: (t, l) => {
if (!listeners.has(t)) listeners.set(t, new Set());
listeners.get(t)!.add(l);
},
removeEventListener: (t, l) => listeners.get(t)?.delete(l),
};
Object.defineProperty(window, "visualViewport", { value: vv, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 800, configurable: true });
});

afterEach(() => {
// @ts-expect-error test cleanup
delete window.visualViewport;
});

it("returns height + keyboardInset=0 when viewport matches window", () => {
const { result } = renderHook(() => useVisualViewport());
expect(result.current.height).toBe(800);
expect(result.current.keyboardInset).toBe(0);
});

it("computes keyboardInset when viewport shrinks (keyboard open)", () => {
const { result } = renderHook(() => useVisualViewport());
act(() => {
vv.height = 500;
listeners.get("resize")?.forEach((l) => l());
});
expect(result.current.keyboardInset).toBe(300);
});

it("returns fallback when visualViewport is undefined", () => {
// @ts-expect-error delete VV
delete window.visualViewport;
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
const { result } = renderHook(() => useVisualViewport());
expect(result.current.height).toBe(600);
expect(result.current.keyboardInset).toBe(0);
});
});
33 changes: 33 additions & 0 deletions desktop/src/hooks/use-visual-viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";

export interface VisualViewportState {
height: number;
keyboardInset: number;
}

function read(): VisualViewportState {
if (typeof window === "undefined") return { height: 0, keyboardInset: 0 };
const vv = window.visualViewport;
if (!vv) return { height: window.innerHeight, keyboardInset: 0 };
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
return { height: vv.height, keyboardInset: inset };
}

export function useVisualViewport(): VisualViewportState {
const [state, setState] = useState<VisualViewportState>(read);

useEffect(() => {
if (typeof window === "undefined") return;
const vv = window.visualViewport;
if (!vv) return;
const update = () => setState(read());
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
return () => {
vv.removeEventListener("resize", update);
vv.removeEventListener("scroll", update);
};
}, []);

return state;
}
131 changes: 131 additions & 0 deletions desktop/src/shell/BottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from "react";

export interface BottomSheetProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
labelledBy?: string;
dragHandle?: boolean;
}

const DISMISS_THRESHOLD_PX = 80;

export function BottomSheet({
open, onClose, children, labelledBy, dragHandle = true,
}: BottomSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const [dragY, setDragY] = useState(0);

useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { e.preventDefault(); onClose(); }
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);

useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const first = sheetRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
first?.focus();
return () => {
previouslyFocused?.focus?.();
};
}, [open]);

// Tab-cycling focus trap: keep focus inside the sheet while open.
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const root = sheetRef.current;
if (!root) return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusables.length === 0) return;
const first = focusables[0]!;
const last = focusables[focusables.length - 1]!;
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!open) return null;

const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const startY = e.clientY;
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
const dy = Math.max(0, ev.clientY - startY);
setDragY(dy);
};
const onUp = (ev: PointerEvent) => {
const dy = Math.max(0, ev.clientY - startY);
el.releasePointerCapture(e.pointerId);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointercancel", onCancel);
if (dy > DISMISS_THRESHOLD_PX) onClose();
setDragY(0);
};
const onCancel = () => {
el.releasePointerCapture(e.pointerId);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerup", onUp);
el.removeEventListener("pointercancel", onCancel);
setDragY(0);
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerup", onUp);
el.addEventListener("pointercancel", onCancel);
};

return (
<>
<div
data-testid="bottom-sheet-backdrop"
className="fixed inset-0 z-50 bg-black/60"
onClick={onClose}
/>
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
className="fixed bottom-0 inset-x-0 z-50 bg-shell-surface rounded-t-xl border-t border-white/10 shadow-2xl max-h-[85vh] overflow-y-auto"
style={{
paddingBottom: "env(safe-area-inset-bottom, 0px)",
transform: `translateY(${dragY}px)`,
transition: dragY === 0 ? "transform 0.2s ease-out" : "none",
}}
>
{dragHandle && (
<div
data-testid="bottom-sheet-handle"
onPointerDown={onPointerDown}
className="flex justify-center py-2 cursor-grab active:cursor-grabbing touch-none"
>
<div className="w-10 h-1 bg-white/20 rounded-full" />
</div>
)}
{children}
</div>
</>
);
}
Loading
Loading