Phase 4: power UX (palette, shortcuts, mentions, mobile, PWA)

Command palette (cmd+K) with fuzzy nav, ticket search, people lookup
and action entries (new ticket, logout, show shortcuts). Opens from
keyboard or user dropdown.

Global keyboard shortcuts via a small useShortcut/useLeaderShortcut
hook: `?` help overlay, `c` new ticket, `g d|t|m|n|s` leader nav.
Tickets list: j/k cursor, Enter open, x toggle select. TicketDetail:
`e` edit, `r` focus comment composer. All guarded against firing
inside text fields.

@mention autocomplete in the comment composer (MentionTextarea) with
arrow-key nav and Tab/Enter insert. Rendered comments and audit log
rewrite @username tokens to links pointing at that user's assignee
filter; unknown usernames left as plain text.

Mobile sweep: TicketDetail sidebar stacks below content on <md,
Settings profile grid collapses to one column, admin tables get
horizontal scroll with a 640px min width, CTI 3-column grid stacks
vertically on <md, New ticket severity/assignee grid same.

PWA: manifest.webmanifest, SVG icon, minimal network-first service
worker for the app shell (never caches /api/*), registered in
production builds only. Theme-color meta + manifest link in index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:36:28 -04:00
parent 4bade22410
commit ef22e92ac8
21 changed files with 976 additions and 28 deletions
+43 -18
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -23,6 +24,8 @@ import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import CTISelect from '../components/CTISelect';
import Avatar from '../components/Avatar';
import MentionTextarea from '../components/MentionTextarea';
import { injectMentionLinks } from '../lib/mentions';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
@@ -99,6 +102,7 @@ export default function TicketDetail() {
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
const [deleteOpen, setDeleteOpen] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [editForm, setEditForm] = useState({ title: '', overview: '' });
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' });
const [statusOpen, setStatusOpen] = useState(false);
@@ -195,6 +199,20 @@ export default function TicketDetail() {
await deleteCommentMutation.mutateAsync(commentId);
};
useShortcut('e', (e) => {
if (!ticket || editing) return;
e.preventDefault();
startEdit();
}, [ticket, editing]);
useShortcut('r', (e) => {
if (!ticket) return;
e.preventDefault();
setTab('comments');
setPreview(false);
setTimeout(() => commentTextareaRef.current?.focus(), 40);
}, [ticket]);
const toggleLog = (logId: string) => {
setExpandedLogs((prev) => {
const next = new Set(prev);
@@ -245,7 +263,7 @@ export default function TicketDetail() {
All tickets
</button>
<div className="flex gap-6 items-start">
<div className="flex flex-col-reverse md:flex-row gap-6 md:items-start">
{/* ── Main content ── */}
<div className="flex-1 min-w-0">
{/* Title card */}
@@ -386,7 +404,9 @@ export default function TicketDetail() {
)}
</div>
<div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.body}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(comment.body, users)}
</ReactMarkdown>
</div>
</div>
</div>
@@ -418,25 +438,30 @@ export default function TicketDetail() {
{preview ? (
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
{commentBody.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(commentBody, users)}
</ReactMarkdown>
) : (
<span className="text-gray-600 italic">Nothing to preview</span>
)}
</div>
) : (
<textarea
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
placeholder="Leave a comment… Markdown supported"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submitComment(e as unknown as React.FormEvent);
<div className="mb-3">
<MentionTextarea
ref={commentTextareaRef}
value={commentBody}
onChange={setCommentBody}
users={users}
onSubmit={() =>
submitComment({
preventDefault: () => {},
} as unknown as React.FormEvent)
}
}}
/>
placeholder="Leave a comment… Markdown & @mentions"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none"
/>
</div>
)}
<div className="flex justify-between items-center border-t border-gray-700 pt-2">
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
@@ -513,7 +538,7 @@ export default function TicketDetail() {
{isComment ? (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{log.detail!}
{injectMentionLinks(log.detail!, users)}
</ReactMarkdown>
</div>
) : (
@@ -533,7 +558,7 @@ export default function TicketDetail() {
</div>
{/* ── Sidebar ── */}
<div className="w-64 flex-shrink-0 sticky top-0 space-y-3">
<div className="w-full md:w-64 flex-shrink-0 md:sticky md:top-0 space-y-3">
{/* Ticket Summary */}
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">