@hienlh/ppm 0.8.60 → 0.8.62

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.
Files changed (162) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-COwDPZl_.js} +1 -1
  3. package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-DCb0mkTp.js} +1 -1
  4. package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-CuUkz5gb.js} +1 -1
  5. package/dist/web/assets/{arc-C2Qaz-ch.js → arc-D0bJaFyD.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-281eTKQ3.js +1 -0
  7. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-BVEUkQYB.js} +1 -1
  8. package/dist/web/assets/arrow-left-C_j9Ki73.js +1 -0
  9. package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-CU2t4NHJ.js} +1 -1
  10. package/dist/web/assets/browser-tab-BnHjUFD1.js +1 -0
  11. package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW-DzjR91sM.js} +1 -1
  12. package/dist/web/assets/channel-CKNZAqoN.js +1 -0
  13. package/dist/web/assets/chat-tab-CyWueJTv.js +7 -0
  14. package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-0YMkpW2S.js} +1 -1
  15. package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-Dp0pTM5r.js} +1 -1
  16. package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CuYKSUgJ.js} +1 -1
  17. package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-DvbvLUIN.js} +2 -2
  18. package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-CcEW1AMZ.js} +1 -1
  19. package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-Cgt-qg75.js} +1 -1
  20. package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-JCLgVcaC.js} +1 -1
  21. package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-B82RP9ow.js} +1 -1
  22. package/dist/web/assets/chunk-GLR3WWYH-Bx2UL5jF.js +2 -0
  23. package/dist/web/assets/chunk-HHEYEP7N-BnRVfNc5.js +1 -0
  24. package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-Pb-JMOgO.js} +1 -1
  25. package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-BRj-ZEvL.js} +1 -1
  26. package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-CBRPKraG.js} +1 -1
  27. package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-DNFj84V6.js} +1 -1
  28. package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-BnPzQK-O.js} +1 -1
  29. package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-BRj25yO7.js} +1 -1
  30. package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-BdXwVXjJ.js} +1 -1
  31. package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-LfXT4p8B.js} +1 -1
  32. package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-EdgQyTqa.js} +1 -1
  33. package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-D3thuSok.js} +1 -1
  34. package/dist/web/assets/chunk-QZHKN3VN-gaBt0Rbd.js +1 -0
  35. package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-LdG7RqsM.js} +1 -1
  36. package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-BHFnnXOt.js} +1 -1
  37. package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-DUmQrLsF.js} +1 -1
  38. package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-CsGTseUr.js} +1 -1
  39. package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-5W2emiq4.js} +1 -1
  40. package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-COdZIaX4.js} +1 -1
  41. package/dist/web/assets/classDiagram-VBA2DB6C-CqaIqYPn.js +1 -0
  42. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bo5WN2ok.js +1 -0
  43. package/dist/web/assets/clone-DNDy9Sms.js +1 -0
  44. package/dist/web/assets/{code-editor-DMw26mUm.js → code-editor-DLXTYEm2.js} +1 -1
  45. package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-C1QJ6GPW.js} +1 -1
  46. package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-CWo8w9wK.js} +1 -1
  47. package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-Br4t5TRV.js} +1 -1
  48. package/dist/web/assets/database-viewer-eqHDuoj7.js +1 -0
  49. package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-CkDC2uAj.js} +1 -1
  50. package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-NvhckwcA.js} +1 -1
  51. package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO--nUaNiyB.js} +1 -1
  52. package/dist/web/assets/{diff-viewer-DVqfhdBN.js → diff-viewer-DFwFZ_k5.js} +1 -1
  53. package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-DK4QEZYh.js} +1 -1
  54. package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-B9h_Ba-v.js} +1 -1
  55. package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-BVlftqyZ.js} +1 -1
  56. package/dist/web/assets/git-graph-Fu6M3rOo.js +1 -0
  57. package/dist/web/assets/gitGraph-HDMCJU4V-D5qEPjgs.js +1 -0
  58. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-L7sj3Bs-.js} +1 -1
  59. package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-BbbiUImY.js} +1 -1
  60. package/dist/web/assets/index-Bf4IsWu9.js +37 -0
  61. package/dist/web/assets/index-n0Ww6i6b.css +2 -0
  62. package/dist/web/assets/info-3K5VOQVL-CbpovIYU.js +1 -0
  63. package/dist/web/assets/infoDiagram-LFFYTUFH-DFh9c-S2.js +2 -0
  64. package/dist/web/assets/input-DGlv6gt_.js +41 -0
  65. package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-DXomfd7J.js} +1 -1
  66. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-cW7SMLa_.js} +1 -1
  67. package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-DFQXUZsc.js} +1 -1
  68. package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-BMUhjxqj.js} +1 -1
  69. package/dist/web/assets/keybindings-store-BTg2T4RA.js +1 -0
  70. package/dist/web/assets/{line-DBLLF7lH.js → line--xyfYP3x.js} +1 -1
  71. package/dist/web/assets/{linear-BLFWatDe.js → linear-BdqW7iQu.js} +1 -1
  72. package/dist/web/assets/{markdown-renderer--Ss7hHOm.js → markdown-renderer-B7o8ysmw.js} +5 -5
  73. package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-BY8JfkE_.js} +2 -2
  74. package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-DIv-LMXG.js} +1 -1
  75. package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-CIoJK3nc.js} +1 -1
  76. package/dist/web/assets/packet-RMMSAZCW-BbzPU9BK.js +1 -0
  77. package/dist/web/assets/pie-UPGHQEXC-B0h6hM1j.js +1 -0
  78. package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-seSK40d1.js} +1 -1
  79. package/dist/web/assets/postgres-viewer-BMJBkwN7.js +1 -0
  80. package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-BaRFqlsA.js} +1 -1
  81. package/dist/web/assets/radar-KQ55EAFF-CHptMqVT.js +1 -0
  82. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-1WWjMQB_.js} +1 -1
  83. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-DEGGYsk7.js} +1 -1
  84. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-BtRvoUTC.js} +1 -1
  85. package/dist/web/assets/{settings-store-Bbhg_ptG.js → settings-store-D3dJqGhB.js} +2 -2
  86. package/dist/web/assets/settings-tab-D4FbV-Xi.js +1 -0
  87. package/dist/web/assets/sqlite-viewer-BIdCcD9_.js +1 -0
  88. package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-C16aO8tn.js} +1 -1
  89. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-D7qSAjnK.js +1 -0
  90. package/dist/web/assets/switch-goUjvGec.js +1 -0
  91. package/dist/web/assets/{tab-store-DcIBZTD4.js → tab-store-DSz5PQI0.js} +1 -1
  92. package/dist/web/assets/{terminal-tab--Ag9kqvS.js → terminal-tab-1CtxESHt.js} +1 -1
  93. package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-DrjxCpEM.js} +1 -1
  94. package/dist/web/assets/treemap-KZPCXAKY-BL9OJq3X.js +1 -0
  95. package/dist/web/assets/{use-monaco-theme-DHbyUrzJ.js → use-monaco-theme-BQzvItNE.js} +1 -1
  96. package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-DfYFnniI.js} +1 -1
  97. package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-BRvXOVlG.js} +1 -1
  98. package/dist/web/index.html +12 -10
  99. package/dist/web/sw.js +1 -1
  100. package/docs/code-standards.md +106 -7
  101. package/docs/project-changelog.md +80 -1
  102. package/docs/streaming-input-guide.md +267 -0
  103. package/docs/system-architecture.md +177 -12
  104. package/package.json +1 -1
  105. package/src/server/index.ts +4 -0
  106. package/src/server/routes/browser-preview.ts +89 -0
  107. package/src/web/components/browser/browser-tab.tsx +269 -0
  108. package/src/web/components/chat/message-input.tsx +68 -2
  109. package/src/web/components/layout/command-palette.tsx +4 -0
  110. package/src/web/components/layout/editor-panel.tsx +1 -0
  111. package/src/web/components/layout/mobile-nav.tsx +2 -2
  112. package/src/web/components/layout/tab-bar.tsx +2 -0
  113. package/src/web/components/layout/tab-content.tsx +5 -0
  114. package/src/web/hooks/use-global-keybindings.ts +7 -0
  115. package/src/web/hooks/use-voice-input.ts +111 -0
  116. package/src/web/stores/keybindings-store.ts +1 -0
  117. package/src/web/stores/tab-store.ts +2 -1
  118. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
  119. package/dist/web/assets/channel-w7yboq56.js +0 -1
  120. package/dist/web/assets/chat-tab-C5H74y2z.js +0 -7
  121. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
  122. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
  123. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
  124. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
  125. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
  126. package/dist/web/assets/clone-BSi6cgDh.js +0 -1
  127. package/dist/web/assets/database-viewer-gnj_8u4T.js +0 -1
  128. package/dist/web/assets/git-graph-CJy7tOAJ.js +0 -1
  129. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
  130. package/dist/web/assets/index-BAioKo_2.css +0 -2
  131. package/dist/web/assets/index-Dg6TQ3Iu.js +0 -37
  132. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
  133. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
  134. package/dist/web/assets/input-Brjz2Vv-.js +0 -41
  135. package/dist/web/assets/keybindings-store-DcxZ6WAa.js +0 -1
  136. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
  137. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
  138. package/dist/web/assets/postgres-viewer-DMcvp0H7.js +0 -1
  139. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
  140. package/dist/web/assets/settings-tab-lC12I-a1.js +0 -1
  141. package/dist/web/assets/sqlite-viewer-BK2emL4i.js +0 -1
  142. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
  143. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
  144. /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-icCZ-07C.js} +0 -0
  145. /package/dist/web/assets/{array-BGFCBI0e.js → array-CLwNaqU1.js} +0 -0
  146. /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-Bcg3QJBg.js} +0 -0
  147. /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-B-QQuWwK.js} +0 -0
  148. /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-D_VMtRaY.js} +0 -0
  149. /package/dist/web/assets/{dist-T0Vhi0Mh.js → dist-CMmNEgEP.js} +0 -0
  150. /package/dist/web/assets/{dist-Cce3efmT.js → dist-Ckxnw5rl.js} +0 -0
  151. /package/dist/web/assets/{init-B8gtcn7T.js → init-vVpfz1D6.js} +0 -0
  152. /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-DvHDmeBe.js} +0 -0
  153. /package/dist/web/assets/{katex-Bbu770d9.js → katex-C3cZrCvP.js} +0 -0
  154. /package/dist/web/assets/{math-DwgHI-Cu.js → math-a44lmFDa.js} +0 -0
  155. /package/dist/web/assets/{path-DZF-JdEe.js → path-CuyvWNAH.js} +0 -0
  156. /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-CsoeaaUJ.js} +0 -0
  157. /package/dist/web/assets/{react-BGf7KNLk.js → react-BPIfZRKM.js} +0 -0
  158. /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-c4PR5shF.js} +0 -0
  159. /package/dist/web/assets/{src-BoSBNdA_.js → src-CLWraeNW.js} +0 -0
  160. /package/dist/web/assets/{table-Yo02WRH-.js → table-C9jDaRl2.js} +0 -0
  161. /package/dist/web/assets/{tag-CaC1ng2E.js → tag-CENGyt_L.js} +0 -0
  162. /package/dist/web/assets/{utils-btZ8C8-R.js → utils-Bslrbb-G.js} +0 -0
@@ -0,0 +1,269 @@
1
+ import { useState, useRef, useCallback, useEffect } from "react";
2
+ import {
3
+ ArrowLeft,
4
+ ArrowRight,
5
+ RotateCcw,
6
+ ExternalLink,
7
+ Globe,
8
+ } from "lucide-react";
9
+ import { useTabStore } from "@/stores/tab-store";
10
+
11
+ /** Parse a URL string — returns normalized URL or null if invalid */
12
+ function parseUrl(input: string): string | null {
13
+ let url = input.trim();
14
+ if (!url) return null;
15
+
16
+ // If just a port number, treat as localhost
17
+ if (/^\d+$/.test(url)) return `http://localhost:${url}`;
18
+
19
+ // If host:port without scheme, add http://
20
+ if (/^localhost(:\d+)?/.test(url)) url = `http://${url}`;
21
+ if (/^[\w.-]+:\d+/.test(url) && !url.includes("://")) url = `http://${url}`;
22
+
23
+ // If no scheme at all, add https:// for external, http:// for localhost
24
+ if (!url.includes("://")) {
25
+ url = url.includes("localhost") ? `http://${url}` : `https://${url}`;
26
+ }
27
+
28
+ try {
29
+ new URL(url);
30
+ return url;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** Check if a URL is a localhost address */
37
+ function isLocalhost(url: string): boolean {
38
+ try {
39
+ const u = new URL(url);
40
+ return (
41
+ u.hostname === "localhost" ||
42
+ u.hostname === "127.0.0.1" ||
43
+ u.hostname === "0.0.0.0" ||
44
+ u.hostname === "::1"
45
+ );
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** Convert URL to iframe src — proxy localhost through backend */
52
+ function toIframeSrc(url: string): string {
53
+ if (!isLocalhost(url)) return url;
54
+
55
+ try {
56
+ const u = new URL(url);
57
+ const port = u.port || "80";
58
+ const path = u.pathname + u.search + u.hash;
59
+ return `/api/preview/${port}${path}`;
60
+ } catch {
61
+ return url;
62
+ }
63
+ }
64
+
65
+ /** Extract display URL from iframe src (reverse of toIframeSrc) */
66
+ function fromIframeSrc(src: string): string {
67
+ const match = src.match(/^\/api\/preview\/(\d+)(\/.*)?$/);
68
+ if (match) {
69
+ const port = match[1];
70
+ const path = match[2] || "/";
71
+ return `http://localhost:${port}${path}`;
72
+ }
73
+ return src;
74
+ }
75
+
76
+ interface BrowserTabProps {
77
+ metadata?: Record<string, unknown>;
78
+ tabId?: string;
79
+ }
80
+
81
+ export function BrowserTab({ metadata, tabId }: BrowserTabProps) {
82
+ const initialUrl = (metadata?.url as string) || "http://localhost:3000";
83
+ const [addressBar, setAddressBar] = useState(initialUrl);
84
+ const [currentUrl, setCurrentUrl] = useState(initialUrl);
85
+ const [iframeSrc, setIframeSrc] = useState(toIframeSrc(initialUrl));
86
+ const [canGoBack, setCanGoBack] = useState(false);
87
+ const [canGoForward, setCanGoForward] = useState(false);
88
+ const [loading, setLoading] = useState(true);
89
+ const [error, setError] = useState<string | null>(null);
90
+ const iframeRef = useRef<HTMLIFrameElement>(null);
91
+ const updateTab = useTabStore((s) => s.updateTab);
92
+
93
+ // Navigation history (iframe same-origin only)
94
+ const historyRef = useRef<string[]>([initialUrl]);
95
+ const historyIdxRef = useRef(0);
96
+
97
+ const navigate = useCallback(
98
+ (url: string, addToHistory = true) => {
99
+ const parsed = parseUrl(url);
100
+ if (!parsed) {
101
+ setError("Invalid URL");
102
+ return;
103
+ }
104
+
105
+ setError(null);
106
+ setCurrentUrl(parsed);
107
+ setAddressBar(parsed);
108
+ setIframeSrc(toIframeSrc(parsed));
109
+ setLoading(true);
110
+
111
+ if (addToHistory) {
112
+ const h = historyRef.current;
113
+ const idx = historyIdxRef.current;
114
+ // Truncate forward history
115
+ historyRef.current = h.slice(0, idx + 1);
116
+ historyRef.current.push(parsed);
117
+ historyIdxRef.current = historyRef.current.length - 1;
118
+ }
119
+
120
+ setCanGoBack(historyIdxRef.current > 0);
121
+ setCanGoForward(
122
+ historyIdxRef.current < historyRef.current.length - 1,
123
+ );
124
+
125
+ // Update tab title
126
+ if (tabId) {
127
+ try {
128
+ const u = new URL(parsed);
129
+ const title = isLocalhost(parsed)
130
+ ? `localhost:${u.port || "80"}`
131
+ : u.hostname;
132
+ updateTab(tabId, { title });
133
+ } catch {}
134
+ }
135
+ },
136
+ [tabId, updateTab],
137
+ );
138
+
139
+ const goBack = useCallback(() => {
140
+ if (historyIdxRef.current > 0) {
141
+ historyIdxRef.current--;
142
+ navigate(historyRef.current[historyIdxRef.current]!, false);
143
+ }
144
+ }, [navigate]);
145
+
146
+ const goForward = useCallback(() => {
147
+ if (historyIdxRef.current < historyRef.current.length - 1) {
148
+ historyIdxRef.current++;
149
+ navigate(historyRef.current[historyIdxRef.current]!, false);
150
+ }
151
+ }, [navigate]);
152
+
153
+ const reload = useCallback(() => {
154
+ setLoading(true);
155
+ setError(null);
156
+ if (iframeRef.current) {
157
+ // Force reload by re-setting src
158
+ const src = iframeRef.current.src;
159
+ iframeRef.current.src = "";
160
+ requestAnimationFrame(() => {
161
+ if (iframeRef.current) iframeRef.current.src = src;
162
+ });
163
+ }
164
+ }, []);
165
+
166
+ const openExternal = useCallback(() => {
167
+ window.open(currentUrl, "_blank");
168
+ }, [currentUrl]);
169
+
170
+ const handleAddressKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
171
+ if (e.key === "Enter") {
172
+ e.preventDefault();
173
+ navigate(addressBar);
174
+ }
175
+ };
176
+
177
+ // Navigate when metadata.url changes (e.g. opened from command palette)
178
+ useEffect(() => {
179
+ const metaUrl = metadata?.url as string | undefined;
180
+ if (metaUrl && metaUrl !== currentUrl) {
181
+ navigate(metaUrl);
182
+ }
183
+ }, [metadata?.url]);
184
+
185
+ return (
186
+ <div className="flex flex-col h-full w-full bg-background">
187
+ {/* Toolbar */}
188
+ <div className="flex items-center gap-1 px-2 py-1.5 border-b border-border bg-surface shrink-0">
189
+ {/* Nav buttons */}
190
+ <button
191
+ onClick={goBack}
192
+ disabled={!canGoBack}
193
+ className="p-1.5 rounded hover:bg-surface-elevated disabled:opacity-30 transition-colors"
194
+ title="Back"
195
+ >
196
+ <ArrowLeft className="size-4" />
197
+ </button>
198
+ <button
199
+ onClick={goForward}
200
+ disabled={!canGoForward}
201
+ className="p-1.5 rounded hover:bg-surface-elevated disabled:opacity-30 transition-colors"
202
+ title="Forward"
203
+ >
204
+ <ArrowRight className="size-4" />
205
+ </button>
206
+ <button
207
+ onClick={reload}
208
+ className="p-1.5 rounded hover:bg-surface-elevated transition-colors"
209
+ title="Reload"
210
+ >
211
+ <RotateCcw className={`size-4 ${loading ? "animate-spin" : ""}`} />
212
+ </button>
213
+
214
+ {/* Address bar */}
215
+ <div className="flex-1 flex items-center gap-2 mx-1 px-2.5 py-1.5 rounded-md bg-background border border-border focus-within:border-accent/50 transition-colors">
216
+ <Globe className="size-3.5 text-text-subtle shrink-0" />
217
+ <input
218
+ type="text"
219
+ value={addressBar}
220
+ onChange={(e) => setAddressBar(e.target.value)}
221
+ onKeyDown={handleAddressKeyDown}
222
+ placeholder="Enter URL or port (e.g. 3000, localhost:8080)"
223
+ className="flex-1 bg-transparent text-xs text-text-primary outline-none placeholder:text-text-subtle min-w-0"
224
+ />
225
+ </div>
226
+
227
+ {/* Open external */}
228
+ <button
229
+ onClick={openExternal}
230
+ className="p-1.5 rounded hover:bg-surface-elevated transition-colors"
231
+ title="Open in browser"
232
+ >
233
+ <ExternalLink className="size-4" />
234
+ </button>
235
+ </div>
236
+
237
+ {/* Content */}
238
+ <div className="flex-1 relative min-h-0">
239
+ {error ? (
240
+ <div className="flex items-center justify-center h-full text-text-secondary text-sm">
241
+ <p>{error}</p>
242
+ </div>
243
+ ) : (
244
+ <iframe
245
+ ref={iframeRef}
246
+ src={iframeSrc}
247
+ className="w-full h-full border-0"
248
+ sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
249
+ onLoad={() => setLoading(false)}
250
+ onError={() => {
251
+ setLoading(false);
252
+ setError(`Failed to load ${currentUrl}`);
253
+ }}
254
+ />
255
+ )}
256
+
257
+ {/* Loading overlay */}
258
+ {loading && !error && (
259
+ <div className="absolute inset-0 flex items-center justify-center bg-background/50">
260
+ <div className="flex items-center gap-2 text-sm text-text-secondary">
261
+ <RotateCcw className="size-4 animate-spin" />
262
+ <span>Loading...</span>
263
+ </div>
264
+ </div>
265
+ )}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
@@ -1,5 +1,6 @@
1
1
  import { useState, useRef, useCallback, useEffect, memo, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
2
- import { ArrowUp, Square, Paperclip, Loader2 } from "lucide-react";
2
+ import { ArrowUp, Square, Paperclip, Loader2, Mic, MicOff } from "lucide-react";
3
+ import { useVoiceInput } from "@/hooks/use-voice-input";
3
4
  import { api, projectUrl, getAuthToken } from "@/lib/api-client";
4
5
  import { randomId } from "@/lib/utils";
5
6
  import { isSupportedFile, isImageFile } from "@/lib/file-support";
@@ -74,6 +75,41 @@ export const MessageInput = memo(function MessageInput({
74
75
  const slashItemsRef = useRef<SlashItem[]>([]);
75
76
  const fileItemsRef = useRef<FileNode[]>([]);
76
77
 
78
+ // Voice input (Web Speech API)
79
+ const voice = useVoiceInput();
80
+ // Store pre-voice text so voice appends to existing input
81
+ const preVoiceTextRef = useRef("");
82
+ const voiceResultCb = useCallback((text: string) => {
83
+ const prefix = preVoiceTextRef.current;
84
+ const newValue = prefix ? prefix + " " + text : text;
85
+ setValue(newValue);
86
+ // Auto-resize textarea
87
+ requestAnimationFrame(() => {
88
+ const ta = window.matchMedia("(min-width: 768px)").matches
89
+ ? textareaRef.current
90
+ : mobileTextareaRef.current;
91
+ if (ta) {
92
+ ta.style.height = "auto";
93
+ ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
94
+ }
95
+ });
96
+ }, []);
97
+ const handleVoiceToggle = useCallback(() => {
98
+ if (voice.isListening) {
99
+ voice.stop();
100
+ } else {
101
+ preVoiceTextRef.current = value.trim();
102
+ voice.start(voiceResultCb);
103
+ }
104
+ }, [voice.isListening, voice.start, voice.stop, value, voiceResultCb]);
105
+
106
+ // Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
107
+ useEffect(() => {
108
+ const handler = () => { if (voice.supported) handleVoiceToggle(); };
109
+ window.addEventListener("toggle-voice-input", handler);
110
+ return () => window.removeEventListener("toggle-voice-input", handler);
111
+ }, [voice.supported, handleVoiceToggle]);
112
+
77
113
  // Apply initialValue when it changes (e.g. "Ask AI" from command palette)
78
114
  useEffect(() => {
79
115
  if (initialValue) {
@@ -465,7 +501,7 @@ export const MessageInput = memo(function MessageInput({
465
501
  onOpenChange={setModeSelectorOpen}
466
502
  />
467
503
  </div>
468
- {/* Mobile: single row — attach + textarea + send */}
504
+ {/* Mobile: single row — attach + mic + textarea + send */}
469
505
  <div className="flex items-end gap-1 md:hidden px-2 py-2">
470
506
  <button
471
507
  type="button"
@@ -476,6 +512,21 @@ export const MessageInput = memo(function MessageInput({
476
512
  >
477
513
  <Paperclip className="size-4" />
478
514
  </button>
515
+ {voice.supported && (
516
+ <button
517
+ type="button"
518
+ onClick={(e) => { e.stopPropagation(); handleVoiceToggle(); }}
519
+ disabled={disabled}
520
+ className={`flex items-center justify-center size-7 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
521
+ voice.isListening
522
+ ? "bg-red-600 text-white animate-pulse"
523
+ : "text-text-subtle hover:text-text-primary"
524
+ }`}
525
+ aria-label={voice.isListening ? "Stop voice input" : "Start voice input"}
526
+ >
527
+ {voice.isListening ? <MicOff className="size-4" /> : <Mic className="size-4" />}
528
+ </button>
529
+ )}
479
530
  <textarea
480
531
  ref={mobileTextareaRef}
481
532
  value={value}
@@ -535,6 +586,21 @@ export const MessageInput = memo(function MessageInput({
535
586
  >
536
587
  <Paperclip className="size-4" />
537
588
  </button>
589
+ {voice.supported && (
590
+ <button
591
+ type="button"
592
+ onClick={(e) => { e.stopPropagation(); handleVoiceToggle(); }}
593
+ disabled={disabled}
594
+ className={`flex items-center justify-center size-8 rounded-full transition-colors disabled:opacity-50 ${
595
+ voice.isListening
596
+ ? "bg-red-600 text-white animate-pulse"
597
+ : "text-text-subtle hover:text-text-primary hover:bg-surface-elevated"
598
+ }`}
599
+ aria-label={voice.isListening ? "Stop voice input" : "Start voice input"}
600
+ >
601
+ {voice.isListening ? <MicOff className="size-4" /> : <Mic className="size-4" />}
602
+ </button>
603
+ )}
538
604
  {/* Mode indicator chip */}
539
605
  <div className="relative">
540
606
  <ModeChip
@@ -10,6 +10,8 @@ import {
10
10
  FileCode,
11
11
  FolderOpen,
12
12
  Loader2,
13
+ Globe,
14
+ Mic,
13
15
  } from "lucide-react";
14
16
  import { useTabStore, type TabType } from "@/stores/tab-store";
15
17
  import { useProjectStore } from "@/stores/project-store";
@@ -156,7 +158,9 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
156
158
  { id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action", shortcut: formatShortcut(getBinding("open-chat")) },
157
159
  { id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action", shortcut: formatShortcut(getBinding("open-terminal")) },
158
160
  { id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action", shortcut: formatShortcut(getBinding("open-git-graph")) },
161
+ { id: "browser", label: "Open Browser", icon: Globe, action: openNewTab("browser", "Browser"), keywords: "web preview localhost iframe url", group: "action" },
159
162
  { id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
163
+ { id: "voice-input", label: "Voice Input", icon: Mic, action: () => { window.dispatchEvent(new CustomEvent("toggle-voice-input")); onClose(); }, keywords: "speech microphone dictate voice", group: "action", shortcut: formatShortcut(getBinding("voice-input")) },
160
164
  { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action", shortcut: formatShortcut(getBinding("open-git-status")) },
161
165
  {
162
166
  id: "settings", label: "Settings", icon: Settings,
@@ -23,6 +23,7 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
23
23
  "git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
24
24
  "git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
25
25
  settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
26
+ browser: lazy(() => import("@/components/browser/browser-tab").then((m) => ({ default: m.BrowserTab }))),
26
27
  };
27
28
 
28
29
  interface EditorPanelProps {
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import {
3
3
  Terminal, MessageSquare, GitBranch, Database,
4
4
  FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
5
- ChevronLeft, ChevronRight,
5
+ ChevronLeft, ChevronRight, Globe,
6
6
  } from "lucide-react";
7
7
  import { usePanelStore } from "@/stores/panel-store";
8
8
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
@@ -25,7 +25,7 @@ const NEW_TAB_LABELS: Partial<Record<TabType, string>> = Object.fromEntries(NEW_
25
25
 
26
26
  const TAB_ICONS: Record<TabType, React.ElementType> = {
27
27
  terminal: Terminal, chat: MessageSquare, editor: FileCode, database: Database, sqlite: Database, postgres: Database,
28
- "git-graph": GitBranch, "git-diff": FileDiff, settings: Settings,
28
+ "git-graph": GitBranch, "git-diff": FileDiff, settings: Settings, browser: Globe,
29
29
  };
30
30
 
31
31
  interface MobileNavProps { onMenuPress: () => void; onProjectsPress: () => void; }
@@ -10,6 +10,7 @@ import {
10
10
  Database,
11
11
  ChevronLeft,
12
12
  ChevronRight,
13
+ Globe,
13
14
  } from "lucide-react";
14
15
  import { useTabStore, type TabType } from "@/stores/tab-store";
15
16
  import { usePanelStore } from "@/stores/panel-store";
@@ -33,6 +34,7 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
33
34
  "git-graph": GitBranch,
34
35
  "git-diff": FileDiff,
35
36
  settings: Settings,
37
+ browser: Globe,
36
38
  };
37
39
 
38
40
  interface TabBarProps {
@@ -48,6 +48,11 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
48
48
  default: m.SettingsTab,
49
49
  })),
50
50
  ),
51
+ browser: lazy(() =>
52
+ import("@/components/browser/browser-tab").then((m) => ({
53
+ default: m.BrowserTab,
54
+ })),
55
+ ),
51
56
  };
52
57
 
53
58
  function LoadingFallback() {
@@ -124,6 +124,13 @@ export function useGlobalKeybindings() {
124
124
  return;
125
125
  }
126
126
 
127
+ // Toggle voice input in chat
128
+ if (match(e, "voice-input")) {
129
+ e.preventDefault();
130
+ window.dispatchEvent(new CustomEvent("toggle-voice-input"));
131
+ return;
132
+ }
133
+
127
134
  // Open search (sidebar)
128
135
  if (match(e, "open-search")) {
129
136
  e.preventDefault();
@@ -0,0 +1,111 @@
1
+ import { useState, useRef, useCallback } from "react";
2
+
3
+ // Extend Window for webkit prefix
4
+ interface SpeechRecognitionEvent extends Event {
5
+ results: SpeechRecognitionResultList;
6
+ resultIndex: number;
7
+ }
8
+
9
+ type SpeechRecognitionInstance = {
10
+ lang: string;
11
+ continuous: boolean;
12
+ interimResults: boolean;
13
+ start(): void;
14
+ stop(): void;
15
+ abort(): void;
16
+ onresult: ((event: SpeechRecognitionEvent) => void) | null;
17
+ onend: (() => void) | null;
18
+ onerror: ((event: Event & { error: string }) => void) | null;
19
+ };
20
+
21
+ type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
22
+
23
+ function getSpeechRecognition(): SpeechRecognitionConstructor | null {
24
+ const w = window as unknown as {
25
+ SpeechRecognition?: SpeechRecognitionConstructor;
26
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
27
+ };
28
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
29
+ }
30
+
31
+ export function useVoiceInput(options?: { lang?: string }) {
32
+ const [isListening, setIsListening] = useState(false);
33
+ const [interimText, setInterimText] = useState("");
34
+ const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
35
+ // Accumulate finalized text across multiple result events
36
+ const finalizedRef = useRef("");
37
+
38
+ const supported = typeof window !== "undefined" && getSpeechRecognition() !== null;
39
+
40
+ const start = useCallback(
41
+ (onResult: (text: string, isFinal: boolean) => void) => {
42
+ const SR = getSpeechRecognition();
43
+ if (!SR) return;
44
+
45
+ // Stop any existing session
46
+ recognitionRef.current?.abort();
47
+
48
+ const recognition = new SR();
49
+ recognition.lang = options?.lang ?? "vi-VN";
50
+ recognition.continuous = true;
51
+ recognition.interimResults = true;
52
+
53
+ finalizedRef.current = "";
54
+
55
+ recognition.onresult = (event: SpeechRecognitionEvent) => {
56
+ let interim = "";
57
+ let newFinalized = "";
58
+
59
+ for (let i = 0; i < event.results.length; i++) {
60
+ const result = event.results[i]!;
61
+ if (result.isFinal) {
62
+ newFinalized += result[0]!.transcript;
63
+ } else {
64
+ interim += result[0]!.transcript;
65
+ }
66
+ }
67
+
68
+ // Update finalized accumulator
69
+ if (newFinalized) {
70
+ finalizedRef.current = newFinalized;
71
+ }
72
+
73
+ const fullText = (finalizedRef.current + " " + interim).trim();
74
+ setInterimText(interim);
75
+ onResult(fullText, interim.length === 0 && finalizedRef.current.length > 0);
76
+ };
77
+
78
+ recognition.onend = () => {
79
+ setIsListening(false);
80
+ setInterimText("");
81
+ // Deliver final text if any
82
+ if (finalizedRef.current) {
83
+ onResult(finalizedRef.current.trim(), true);
84
+ }
85
+ };
86
+
87
+ recognition.onerror = (event) => {
88
+ // "no-speech" and "aborted" are expected, not real errors
89
+ if (event.error !== "no-speech" && event.error !== "aborted") {
90
+ console.warn("[voice-input] error:", event.error);
91
+ }
92
+ setIsListening(false);
93
+ setInterimText("");
94
+ };
95
+
96
+ recognitionRef.current = recognition;
97
+ recognition.start();
98
+ setIsListening(true);
99
+ },
100
+ [options?.lang],
101
+ );
102
+
103
+ const stop = useCallback(() => {
104
+ recognitionRef.current?.stop();
105
+ recognitionRef.current = null;
106
+ setIsListening(false);
107
+ setInterimText("");
108
+ }, []);
109
+
110
+ return { isListening, interimText, start, stop, supported };
111
+ }
@@ -36,6 +36,7 @@ export const KEY_ACTIONS: KeyAction[] = [
36
36
  { id: "open-git-graph", label: "Git Graph", category: "tabs", defaultKey: "Mod+G" },
37
37
  { id: "open-git-status", label: "Git Status (sidebar)", category: "tabs", defaultKey: "Mod+Shift+E" },
38
38
  { id: "open-search", label: "Search Files (sidebar)", category: "tabs", defaultKey: "Mod+Shift+F" },
39
+ { id: "voice-input", label: "Voice Input", category: "general", defaultKey: "Mod+Shift+V", note: "Toggle speech-to-text in chat" },
39
40
  // Projects — Mod+1..9
40
41
  ...Array.from({ length: 9 }, (_, i) => ({
41
42
  id: `switch-project-${i + 1}`,
@@ -10,7 +10,8 @@ export type TabType =
10
10
  | "postgres"
11
11
  | "git-graph"
12
12
  | "git-diff"
13
- | "settings";
13
+ | "settings"
14
+ | "browser";
14
15
 
15
16
  export interface Tab {
16
17
  id: string;
@@ -1 +0,0 @@
1
- import"./chunk-XZSTWKYB-BYxFzZwS.js";import{n as e}from"./chunk-R5LLSJPH-euR2RxLN.js";export{e as createArchitectureServices};
@@ -1 +0,0 @@
1
- import{it as e,rt as t}from"./chunk-7R4GIKGN-DXaGAn_K.js";var n=(n,r)=>e.lang.round(t.parse(n)[r]);export{n as t};