first commit

This commit is contained in:
caraction
2025-06-25 14:29:07 +00:00
commit 5ea0e36207
60 changed files with 14674 additions and 0 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist/
build/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
.nyc_output
# IDE/Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
# Temporary folders
tmp/
temp/

675
LICENSE Normal file
View File

@@ -0,0 +1,675 @@
# GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
## Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom
to share and change all versions of a program--to make sure it remains
free software for all its users. We, the Free Software Foundation, use
the GNU General Public License for most of our software; it applies
also to any other work released this way by its authors. You can apply
it to your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you
have certain responsibilities if you distribute copies of the
software, or if you modify it: responsibilities to respect the freedom
of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the
manufacturer can do so. This is fundamentally incompatible with the
aim of protecting users' freedom to change the software. The
systematic pattern of such abuse occurs in the area of products for
individuals to use, which is precisely where it is most unacceptable.
Therefore, we have designed this version of the GPL to prohibit the
practice for those products. If such problems arise substantially in
other domains, we stand ready to extend this provision to those
domains in future versions of the GPL, as needed to protect the
freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish
to avoid the special danger that patents applied to a free program
could make it effectively proprietary. To prevent this, the GPL
assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
## TERMS AND CONDITIONS
### 0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds
of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of
an exact copy. The resulting work is called a "modified version" of
the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user
through a computer network, with no transfer of a copy, is not
conveying.
An interactive user interface displays "Appropriate Legal Notices" to
the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
### 1. Source Code.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of
a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can
regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same
work.
### 2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey,
without conditions so long as your license otherwise remains in force.
You may convey covered works to others for the sole purpose of having
them make modifications exclusively for you, or provide you with
facilities for running those works, provided that you comply with the
terms of this License in conveying all material for which you do not
control copyright. Those thus making or running the covered works for
you must do so exclusively on your behalf, under your direction and
control, on terms that prohibit them from making any copies of your
copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the
conditions stated below. Sublicensing is not allowed; section 10 makes
it unnecessary.
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such
circumvention is effected by exercising rights under this License with
respect to the covered work, and you disclaim any intention to limit
operation or modification of the work as a means of enforcing, against
the work's users, your or third parties' legal rights to forbid
circumvention of technological measures.
### 4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
### 5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these
conditions:
- a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4
to "keep intact all notices".
- c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
### 6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of
sections 4 and 5, provided that you also convey the machine-readable
Corresponding Source under the terms of this License, in one of these
ways:
- a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
- d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal,
family, or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of
coverage. For a particular product received by a particular user,
"normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected
to use, the product. A product is a consumer product regardless of
whether the product has substantial commercial, industrial or
non-consumer uses, unless such uses represent the only significant
mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to
install and execute modified versions of a covered work in that User
Product from a modified version of its Corresponding Source. The
information must suffice to ensure that the continued functioning of
the modified object code is in no case prevented or interfered with
solely because modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or
updates for a work that has been modified or installed by the
recipient, or for the User Product in which it has been modified or
installed. Access to a network may be denied when the modification
itself materially and adversely affects the operation of the network
or violates the rules and protocols for communication across the
network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
### 7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
- e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions; the
above requirements apply either way.
### 8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your license
from a particular copyright holder is reinstated (a) provisionally,
unless and until the copyright holder explicitly and finally
terminates your license, and (b) permanently, if the copyright holder
fails to notify you of the violation by some reasonable means prior to
60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
### 9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run
a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
### 10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
### 11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned
or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within the
scope of its coverage, prohibits the exercise of, or is conditioned on
the non-exercise of one or more of the rights that are specifically
granted under this License. You may not convey a covered work if you
are a party to an arrangement with a third party that is in the
business of distributing software, under which you make payment to the
third party based on the extent of your activity of conveying the
work, and under which the third party grants, to any of the parties
who would receive the covered work from you, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or compilations that contain the
covered work, unless you entered into that arrangement, or that patent
license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
### 12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under
this License and any other pertinent obligations, then as a
consequence you may not convey it at all. For example, if you agree to
terms that obligate you to collect a royalty for further conveying
from those to whom you convey the Program, the only way you could
satisfy both those terms and this License would be to refrain entirely
from conveying the Program.
### 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
### 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in
detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies that a certain numbered version of the GNU General Public
License "or any later version" applies to it, you have the option of
following the terms and conditions either of that numbered version or
of any later version published by the Free Software Foundation. If the
Program does not specify a version number of the GNU General Public
License, you may choose any version ever published by the Free
Software Foundation.
If the Program specifies that a proxy can decide which future versions
of the GNU General Public License can be used, that proxy's public
statement of acceptance of a version permanently authorizes you to
choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
### 15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
CORRECTION.
### 16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
### 17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
## How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest to
attach them to the start of each source file to most effectively state
the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper
mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands \`show w' and \`show c' should show the
appropriate parts of the General Public License. Of course, your
program's commands might be different; for a GUI interface, you would
use an "about box".
You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. For more information on this, and how to apply and follow
the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your
program into proprietary programs. If your program is a subroutine
library, you may consider it more useful to permit linking proprietary
applications with the library. If this is what you want to do, use the
GNU Lesser General Public License instead of this License. But first,
please read <https://www.gnu.org/licenses/why-not-lgpl.html>.

225
README.md Normal file
View File

@@ -0,0 +1,225 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Claude Code UI</h1>
</div>
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's official CLI for AI-assisted coding. You can use it locally or remotely to view your active projects and sessions in claude code and make changes to them the same way you would do it in claude code CLI. This gives you a proper interface that works everywhere.
## Screenshots
<div align="center">
### Desktop View
![Desktop Interface](public/screenshots/desktop-main.png)
*Main interface showing project overview and chat*
### Mobile Experience
![Mobile Interface](public/screenshots/mobile-chat.png)
*Responsive mobile design with touch navigation*
</div>
## Features
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Session Management** - Resume conversations, manage multiple sessions, and track history
## Quick Start
### Prerequisites
- [Node.js](https://nodejs.org/) v16 or higher
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
### Installation
1. **Clone the repository:**
```bash
git clone https://github.com/siteboon/claudecodeui.git
cd claudecodeui
```
2. **Install dependencies:**
```bash
npm install
```
3. **Configure environment:**
```bash
cp .env.example .env
# Edit .env with your preferred settings
```
4. **Start the application:**
```bash
# Development mode (with hot reload)
npm run dev
```
5. **Open your browser:**
- Development: `http://localhost:3001`
## Security & Tools Configuration
**🔒 Important Notice**: All Claude Code tools are **disabled by default**. This prevents potentially harmful operations from running automatically.
### Enabling Tools
To use Claude Code's full functionality, you'll need to manually enable tools:
1. **Open Tools Settings** - Click the gear icon in the sidebar
3. **Enable Selectively** - Turn on only the tools you need
4. **Apply Settings** - Your preferences are saved locally
<div align="center">
![Tools Settings Modal](public/screenshots/tools-modal.png)
*Tools Settings interface - enable only what you need*
</div>
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
## Usage Guide
### Core Features
#### Project Management
The UI automatically discovers Claude Code projects from `~/.claude/projects/` and provides:
- **Visual Project Browser** - All available projects with metadata and session counts
- **Project Actions** - Rename, delete, and organize projects
- **Smart Navigation** - Quick access to recent projects and sessions
#### Chat Interface
- **Real-time Communication** - Stream responses from Claude with WebSocket connection
- **Session Management** - Resume previous conversations or start fresh sessions
- **Message History** - Complete conversation history with timestamps and metadata
- **Multi-format Support** - Text, code blocks, and file references
#### File Explorer & Editor
- **Interactive File Tree** - Browse project structure with expand/collapse navigation
- **Live File Editing** - Read, modify, and save files directly in the interface
- **Syntax Highlighting** - Support for multiple programming languages
- **File Operations** - Create, rename, delete files and directories
#### Session Management
- **Session Persistence** - All conversations automatically saved
- **Session Organization** - Group sessions by project and timestamp
- **Session Actions** - Rename, delete, and export conversation history
- **Cross-device Sync** - Access sessions from any device
### Mobile Experience
- **Responsive Design** - Optimized for all screen sizes
- **Touch-friendly Interface** - Swipe gestures and touch navigation
- **Mobile Navigation** - Bottom tab bar for easy thumb navigation
- **Adaptive Layout** - Collapsible sidebar and smart content prioritization
## Architecture
### System Overview
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Claude CLI │
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Claude CLI Integration** - Process spawning and management
- **Session Management** - JSONL parsing and conversation persistence
- **File System API** - Exposing file browser for projects
### Frontend (React + Vite)
- **React 18** - Modern component architecture with hooks
- **CodeMirror** - Advanced code editor with syntax highlighting
### Contributing
We welcome contributions! Please follow these guidelines:
#### Getting Started
1. **Fork** the repository
2. **Clone** your fork: `git clone <your-fork-url>`
3. **Install** dependencies: `npm install`
4. **Create** a feature branch: `git checkout -b feature/amazing-feature`
#### Development Process
1. **Make your changes** following the existing code style
2. **Test thoroughly** - ensure all features work correctly
3. **Run quality checks**: `npm run lint && npm run format`
4. **Commit** with descriptive messages following [Conventional Commits](https://conventionalcommits.org/)
5. **Push** to your branch: `git push origin feature/amazing-feature`
6. **Submit** a Pull Request with:
- Clear description of changes
- Screenshots for UI changes
- Test results if applicable
#### What to Contribute
- **Bug fixes** - Help us improve stability
- **New features** - Enhance functionality (discuss in issues first)
- **Documentation** - Improve guides and API docs
- **UI/UX improvements** - Better user experience
- **Performance optimizations** - Make it faster
## Troubleshooting
### Common Issues & Solutions
#### "No Claude projects found"
**Problem**: The UI shows no projects or empty project list
**Solutions**:
- Ensure [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) is properly installed
- Run `claude` command in at least one project directory to initialize
- Verify `~/.claude/projects/` directory exists and has proper permissions
d
#### File Explorer Issues
**Problem**: Files not loading, permission errors, empty directories
**Solutions**:
- Check project directory permissions (`ls -la` in terminal)
- Verify the project path exists and is accessible
- Review server console logs for detailed error messages
- Ensure you're not trying to access system directories outside project scope
## License
GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
This project is open source and free to use, modify, and distribute under the GPL v3 license.
## Acknowledgments
### Built With
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
- **[React](https://react.dev/)** - User interface library
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
- **[CodeMirror](https://codemirror.net/)** - Advanced code editor
## Support & Community
### Stay Updated
- **Star** this repository to show support
- **Watch** for updates and new releases
- **Follow** the project for announcements
---
<div align="center">
<strong>Made with care for the Claude Code community</strong>
</div>

48
index.html Normal file
View File

@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Claude Code UI</title>
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- iOS Safari PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Claude UI" />
<!-- iOS Safari Icons -->
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-192x192.png" />
<!-- Theme Color -->
<meta name="theme-color" content="#ffffff" />
<meta name="msapplication-TileColor" content="#ffffff" />
<!-- Prevent zoom on iOS -->
<meta name="format-detection" content="telephone=no" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<!-- Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
</script>
</body>
</html>

6248
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "claude-code-ui",
"version": "1.0.0",
"description": "A web-based UI for Claude Code CLI",
"main": "server/index.js",
"scripts": {
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
"server": "node server/index.js",
"client": "vite --host",
"build": "vite build",
"preview": "vite preview",
"start": "npm run build && npm run server"
},
"keywords": [
"claude",
"ai",
"code",
"ui",
"assistant"
],
"author": "Claude Code UI Contributors",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.24",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-webgl": "^0.18.0",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"express": "^4.18.2",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"node-pty": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"postcss": "^8.4.32",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"vite": "^5.0.8"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

53
public/convert-icons.md Normal file
View File

@@ -0,0 +1,53 @@
# Convert SVG Icons to PNG
I've created SVG versions of the app icons that match the MessageSquare design from the sidebar. To convert them to PNG format, you can use one of these methods:
## Method 1: Online Converter (Easiest)
1. Go to https://cloudconvert.com/svg-to-png
2. Upload each SVG file from the `/icons/` directory
3. Download the PNG versions
4. Replace the existing PNG files
## Method 2: Using Node.js (if you have it)
```bash
npm install sharp
node -e "
const sharp = require('sharp');
const fs = require('fs');
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
sizes.forEach(size => {
const svgPath = \`./icons/icon-\${size}x\${size}.svg\`;
const pngPath = \`./icons/icon-\${size}x\${size}.png\`;
if (fs.existsSync(svgPath)) {
sharp(svgPath).png().toFile(pngPath);
console.log(\`Converted \${svgPath} to \${pngPath}\`);
}
});
"
```
## Method 3: Using ImageMagick (if installed)
```bash
cd public/icons
for size in 72 96 128 144 152 192 384 512; do
convert "icon-${size}x${size}.svg" "icon-${size}x${size}.png"
done
```
## Method 4: Using Inkscape (if installed)
```bash
cd public/icons
for size in 72 96 128 144 152 192 384 512; do
inkscape --export-type=png "icon-${size}x${size}.svg"
done
```
## Icon Design
The new icons feature:
- Clean MessageSquare (chat bubble) design matching the sidebar
- Primary color background with rounded corners
- White stroke icon that's clearly visible
- Consistent sizing and proportions across all sizes
- Proper PWA-compliant format
Once converted, the PNG files will replace the existing ones and provide a consistent icon experience across all platforms.

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

9
public/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Background fills entire canvas -->
<rect x="0" y="0" width="64" height="64" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - exact same as sidebar -->
<g transform="translate(32, 32) scale(1.333) translate(-12, -12)" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 504 B

49
public/generate-icons.js Normal file
View File

@@ -0,0 +1,49 @@
const fs = require('fs');
const path = require('path');
// Icon sizes needed
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
// SVG template function
function createIconSVG(size) {
const cornerRadius = Math.round(size * 0.25); // 25% corner radius
const strokeWidth = Math.max(2, Math.round(size * 0.06)); // Scale stroke width
// MessageSquare path scaled to size
const padding = Math.round(size * 0.25);
const iconSize = size - (padding * 2);
const startX = padding;
const startY = Math.round(padding * 0.7);
const endX = startX + iconSize;
const endY = startY + Math.round(iconSize * 0.6);
const tailX = startX;
const tailY = endY + Math.round(iconSize * 0.3);
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background with rounded corners -->
<rect width="${size}" height="${size}" rx="${cornerRadius}" fill="hsl(262.1 83.3% 57.8%)"/>
<!-- MessageSquare icon -->
<path d="M${startX} ${startY}C${startX} ${startY - 10} ${startX + 10} ${startY - 20} ${startX + 20} ${startY - 20}H${endX - 20}C${endX - 10} ${startY - 20} ${endX} ${startY - 10} ${endX} ${startY}V${endY - 20}C${endX} ${endY - 10} ${endX - 10} ${endY} ${endX - 20} ${endY}H${startX + Math.round(iconSize * 0.4)}L${tailX} ${tailY}V${startY}Z"
stroke="white"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>`;
}
// Generate SVG files for each size
sizes.forEach(size => {
const svgContent = createIconSVG(size);
const filename = `icon-${size}x${size}.svg`;
const filepath = path.join(__dirname, 'icons', filename);
fs.writeFileSync(filepath, svgContent);
console.log(`Created ${filename}`);
});
console.log('\nSVG icons created! To convert to PNG, you can use:');
console.log('1. Online converter like cloudconvert.com');
console.log('2. If you have ImageMagick: convert icon.svg icon.png');
console.log('3. If you have Inkscape: inkscape --export-type=png icon.svg');

View File

@@ -0,0 +1,19 @@
# PWA Icons Required
Create the following icon files in this directory:
- icon-72x72.png
- icon-96x96.png
- icon-128x128.png
- icon-144x144.png
- icon-152x152.png
- icon-192x192.png
- icon-384x384.png
- icon-512x512.png
You can use any icon generator tool or create them manually. The icons should be square and represent your Claude Code UI application.
For a quick solution, you can:
1. Create a simple square PNG icon (512x512)
2. Use online tools like realfavicongenerator.net to generate all sizes
3. Or use ImageMagick: `convert icon-512x512.png -resize 192x192 icon-192x192.png`

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

View File

@@ -0,0 +1,12 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background fills entire canvas - iOS will handle corner rounding -->
<rect width="512" height="512" fill="hsl(240 5.9% 10%)"/>
<!-- MessageSquare icon - scaled and centered -->
<path d="M128 144C128 126.327 142.327 112 160 112H352C369.673 112 384 126.327 384 144V272C384 289.673 369.673 304 352 304H224L128 400V144Z"
stroke="white"
stroke-width="32"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

9
public/logo.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="hsl(262.1 83.3% 57.8%)"/>
<path d="M8 9C8 8.44772 8.44772 8 9 8H23C23.5523 8 24 8.44772 24 9V18C24 18.5523 23.5523 19 23 19H12L8 23V9Z"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

61
public/manifest.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "Claude Code UI",
"short_name": "Claude UI",
"description": "Claude Code UI web application",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

49
public/sw.js Normal file
View File

@@ -0,0 +1,49 @@
// Service Worker for Claude Code UI PWA
const CACHE_NAME = 'claude-ui-v1';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json'
];
// Install event
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(urlsToCache);
})
);
self.skipWaiting();
});
// Fetch event
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached response if found
if (response) {
return response;
}
// Otherwise fetch from network
return fetch(event.request);
}
)
);
});
// Activate event
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});

207
server/claude-cli.js Normal file
View File

@@ -0,0 +1,207 @@
const { spawn } = require('child_process');
let activeClaudeProcesses = new Map(); // Track active processes by session ID
async function spawnClaude(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
// Build Claude CLI command - start with print/resume flags first
const args = [];
// Add print flag with command if we have a command
if (command && command.trim()) {
args.push('--print', command);
}
// Add resume flag if resuming
if (resume && sessionId) {
args.push('--resume', sessionId);
}
// Add basic flags
args.push('--output-format', 'stream-json', '--verbose');
// Add model for new sessions
if (!resume) {
args.push('--model', 'sonnet');
}
// Add tools settings flags
if (settings.skipPermissions) {
args.push('--dangerously-skip-permissions');
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
} else {
// Only add allowed/disallowed tools if not skipping permissions
// Add allowed tools
if (settings.allowedTools && settings.allowedTools.length > 0) {
for (const tool of settings.allowedTools) {
args.push('--allowedTools', tool);
console.log('✅ Allowing tool:', tool);
}
}
// Add disallowed tools
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
for (const tool of settings.disallowedTools) {
args.push('--disallowedTools', tool);
console.log('❌ Disallowing tool:', tool);
}
}
}
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
const workingDir = cwd || process.cwd();
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
}).join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
console.log('🔍 Full command args:', args);
const claudeProcess = spawn('claude', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
// Store process reference for potential abort
const processKey = capturedSessionId || sessionId || Date.now().toString();
activeClaudeProcesses.set(processKey, claudeProcess);
// Handle stdout (streaming JSON responses)
claudeProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('📤 Claude CLI stdout:', rawOutput);
const lines = rawOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
console.log('📄 Parsed JSON response:', response);
// Capture session ID if it's in the response
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('📝 Captured session ID:', capturedSessionId);
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeClaudeProcesses.delete(processKey);
activeClaudeProcesses.set(capturedSessionId, claudeProcess);
}
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
type: 'session-created',
sessionId: capturedSessionId
}));
}
}
// Send parsed response to WebSocket
ws.send(JSON.stringify({
type: 'claude-response',
data: response
}));
} catch (parseError) {
console.log('📄 Non-JSON response:', line);
// If not JSON, send as raw text
ws.send(JSON.stringify({
type: 'claude-output',
data: line
}));
}
}
});
// Handle stderr
claudeProcess.stderr.on('data', (data) => {
console.error('Claude CLI stderr:', data.toString());
ws.send(JSON.stringify({
type: 'claude-error',
error: data.toString()
}));
});
// Handle process completion
claudeProcess.on('close', (code) => {
console.log(`Claude CLI process exited with code ${code}`);
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeClaudeProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
type: 'claude-complete',
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
}));
if (code === 0) {
resolve();
} else {
reject(new Error(`Claude CLI exited with code ${code}`));
}
});
// Handle process errors
claudeProcess.on('error', (error) => {
console.error('Claude CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeClaudeProcesses.delete(finalSessionId);
ws.send(JSON.stringify({
type: 'claude-error',
error: error.message
}));
reject(error);
});
// Handle stdin for interactive mode
if (command) {
// For --print mode with arguments, we don't need to write to stdin
claudeProcess.stdin.end();
} else {
// For interactive mode, we need to write the command to stdin if provided later
// Keep stdin open for interactive session
if (command !== undefined) {
claudeProcess.stdin.write(command + '\n');
claudeProcess.stdin.end();
}
// If no command provided, stdin stays open for interactive use
}
});
}
function abortClaudeSession(sessionId) {
const process = activeClaudeProcesses.get(sessionId);
if (process) {
console.log(`🛑 Aborting Claude session: ${sessionId}`);
process.kill('SIGTERM');
activeClaudeProcesses.delete(sessionId);
return true;
}
return false;
}
module.exports = {
spawnClaude,
abortClaudeSession
};

View File

@@ -0,0 +1,162 @@
const { spawn } = require('child_process');
async function spawnClaude(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
console.log('🔧 Using tools settings:', settings);
// Build Claude CLI command - start with basic flags
const args = ['--output-format', 'stream-json', '--verbose'];
// Add tools settings flags
if (settings.skipPermissions) {
args.push('--dangerously-skip-permissions');
console.log('⚠️ Using --dangerously-skip-permissions');
}
// Add print flag if we have a command
if (command && command.trim()) {
args.push('--print');
}
// Add resume flag if resuming (after --print)
if (resume && sessionId) {
args.push('--resume', sessionId);
}
// Add allowed tools
if (settings.allowedTools && settings.allowedTools.length > 0) {
for (const tool of settings.allowedTools) {
args.push('--allowedTools', tool);
console.log('✅ Allowing tool:', tool);
}
}
// Add disallowed tools
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
for (const tool of settings.disallowedTools) {
args.push('--disallowedTools', tool);
console.log('❌ Disallowing tool:', tool);
}
}
// Add the command as the final argument
if (command && command.trim()) {
args.push(command);
}
const workingDir = projectPath || cwd || process.cwd();
console.log('Spawning Claude CLI:', 'claude', args.join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
const claudeProcess = spawn('claude', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe']
});
// Handle stdout (streaming JSON responses)
claudeProcess.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
// Capture session ID if it's in the response
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('📝 Captured session ID:', capturedSessionId);
// Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(JSON.stringify({
type: 'session-created',
sessionId: capturedSessionId
}));
}
}
// Send parsed response to WebSocket
ws.send(JSON.stringify({
type: 'claude-response',
data: response
}));
} catch (parseError) {
// If not JSON, send as raw text
ws.send(JSON.stringify({
type: 'claude-output',
data: line
}));
}
}
});
// Handle stderr
claudeProcess.stderr.on('data', (data) => {
console.error('Claude CLI stderr:', data.toString());
ws.send(JSON.stringify({
type: 'claude-error',
error: data.toString()
}));
});
// Handle process completion
claudeProcess.on('close', (code) => {
console.log(`Claude CLI process exited with code ${code}`);
ws.send(JSON.stringify({
type: 'claude-complete',
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
}));
if (code === 0) {
resolve();
} else {
reject(new Error(`Claude CLI exited with code ${code}`));
}
});
// Handle process errors
claudeProcess.on('error', (error) => {
console.error('Claude CLI process error:', error);
ws.send(JSON.stringify({
type: 'claude-error',
error: error.message
}));
reject(error);
});
// Handle stdin for interactive mode
if (command) {
// For --print mode with arguments, we don't need to write to stdin
claudeProcess.stdin.end();
} else {
// For interactive mode, we need to write the command to stdin if provided later
// Keep stdin open for interactive session
if (command !== undefined) {
claudeProcess.stdin.write(command + '\n');
claudeProcess.stdin.end();
}
// If no command provided, stdin stays open for interactive use
}
});
}
module.exports = {
spawnClaude
};

719
server/index.js Normal file
View File

@@ -0,0 +1,719 @@
// Load environment variables from .env file
try {
const fs = require('fs');
const path = require('path');
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#')) {
const [key, ...valueParts] = trimmedLine.split('=');
if (key && valueParts.length > 0 && !process.env[key]) {
process.env[key] = valueParts.join('=').trim();
}
}
});
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
}
console.log('PORT from env:', process.env.PORT);
const express = require('express');
const { WebSocketServer } = require('ws');
const http = require('http');
const path = require('path');
const cors = require('cors');
const fs = require('fs').promises;
const { spawn } = require('child_process');
const os = require('os');
const pty = require('node-pty');
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
// File system watcher for projects folder
let projectsWatcher = null;
const connectedClients = new Set();
// Setup file system watcher for Claude projects folder using chokidar
function setupProjectsWatcher() {
const chokidar = require('chokidar');
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
if (projectsWatcher) {
projectsWatcher.close();
}
try {
// Initialize chokidar watcher with optimized settings
projectsWatcher = chokidar.watch(claudeProjectsPath, {
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
'**/*.tmp',
'**/*.swp',
'**/.DS_Store'
],
persistent: true,
ignoreInitial: true, // Don't fire events for existing files on startup
followSymlinks: false,
depth: 10, // Reasonable depth limit
awaitWriteFinish: {
stabilityThreshold: 100, // Wait 100ms for file to stabilize
pollInterval: 50
}
});
// Debounce function to prevent excessive notifications
let debounceTimer;
const debouncedUpdate = async (eventType, filePath) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
try {
// Get updated projects list
const updatedProjects = await getProjects();
// Notify all connected clients about the project changes
const updateMessage = JSON.stringify({
type: 'projects_updated',
projects: updatedProjects,
timestamp: new Date().toISOString(),
changeType: eventType,
changedFile: path.relative(claudeProjectsPath, filePath)
});
connectedClients.forEach(client => {
if (client.readyState === client.OPEN) {
client.send(updateMessage);
}
});
} catch (error) {
console.error('❌ Error handling project changes:', error);
}
}, 300); // 300ms debounce (slightly faster than before)
};
// Set up event listeners
projectsWatcher
.on('add', (filePath) => debouncedUpdate('add', filePath))
.on('change', (filePath) => debouncedUpdate('change', filePath))
.on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
.on('error', (error) => {
console.error('❌ Chokidar watcher error:', error);
})
.on('ready', () => {
});
} catch (error) {
console.error('❌ Failed to setup projects watcher:', error);
}
}
// Get the first non-localhost IP address
function getServerIP() {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return 'localhost';
}
const app = express();
const server = http.createServer(app);
// Single WebSocket server that handles both paths
const wss = new WebSocketServer({
server,
verifyClient: (info) => {
console.log('WebSocket connection attempt to:', info.req.url);
return true; // Accept all connections for now
}
});
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../dist')));
// API Routes
app.get('/api/config', (req, res) => {
// Always use the server's actual IP and port for WebSocket connections
const serverIP = getServerIP();
const host = `${serverIP}:${PORT}`;
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
res.json({
serverPort: PORT,
wsUrl: `${protocol}://${host}`
});
});
app.get('/api/projects', async (req, res) => {
try {
const projects = await getProjects();
res.json(projects);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/projects/:projectName/sessions', async (req, res) => {
try {
const { limit = 5, offset = 0 } = req.query;
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get messages for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const messages = await getSessionMessages(projectName, sessionId);
res.json({ messages });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Rename project endpoint
app.put('/api/projects/:projectName/rename', async (req, res) => {
try {
const { displayName } = req.body;
await renameProject(req.params.projectName, displayName);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete session endpoint
app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) => {
try {
const { projectName, sessionId } = req.params;
await deleteSession(projectName, sessionId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete project endpoint (only if empty)
app.delete('/api/projects/:projectName', async (req, res) => {
try {
const { projectName } = req.params;
await deleteProject(projectName);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create project endpoint
app.post('/api/projects/create', async (req, res) => {
try {
const { path: projectPath } = req.body;
if (!projectPath || !projectPath.trim()) {
return res.status(400).json({ error: 'Project path is required' });
}
const project = await addProjectManually(projectPath.trim());
res.json({ success: true, project });
} catch (error) {
console.error('Error creating project:', error);
res.status(500).json({ error: error.message });
}
});
// Read file content endpoint
app.get('/api/projects/:projectName/file', async (req, res) => {
try {
const { projectName } = req.params;
const { filePath } = req.query;
console.log('📄 File read request:', projectName, filePath);
const fs = require('fs').promises;
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const content = await fs.readFile(filePath, 'utf8');
res.json({ content, path: filePath });
} catch (error) {
console.error('Error reading file:', error);
if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File not found' });
} else if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: error.message });
}
}
});
// Serve binary file content endpoint (for images, etc.)
app.get('/api/projects/:projectName/files/content', async (req, res) => {
try {
const { projectName } = req.params;
const { path: filePath } = req.query;
console.log('🖼️ Binary file serve request:', projectName, filePath);
const fs = require('fs');
const mime = require('mime-types');
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
// Check if file exists
try {
await fs.promises.access(filePath);
} catch (error) {
return res.status(404).json({ error: 'File not found' });
}
// Get file extension and set appropriate content type
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
res.setHeader('Content-Type', mimeType);
// Stream the file
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
fileStream.on('error', (error) => {
console.error('Error streaming file:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Error reading file' });
}
});
} catch (error) {
console.error('Error serving binary file:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
});
// Save file content endpoint
app.put('/api/projects/:projectName/file', async (req, res) => {
try {
const { projectName } = req.params;
const { filePath, content } = req.body;
console.log('💾 File save request:', projectName, filePath);
const fs = require('fs').promises;
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
if (content === undefined) {
return res.status(400).json({ error: 'Content is required' });
}
// Create backup of original file
try {
const backupPath = filePath + '.backup.' + Date.now();
await fs.copyFile(filePath, backupPath);
console.log('📋 Created backup:', backupPath);
} catch (backupError) {
console.warn('Could not create backup:', backupError.message);
}
// Write the new content
await fs.writeFile(filePath, content, 'utf8');
res.json({
success: true,
path: filePath,
message: 'File saved successfully'
});
} catch (error) {
console.error('Error saving file:', error);
if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File or directory not found' });
} else if (error.code === 'EACCES') {
res.status(403).json({ error: 'Permission denied' });
} else {
res.status(500).json({ error: error.message });
}
}
});
app.get('/api/projects/:projectName/files', async (req, res) => {
try {
const fs = require('fs').promises;
const projectPath = path.join(process.env.HOME, '.claude', 'projects', req.params.projectName);
// Try different methods to get the actual project path
let actualPath = projectPath;
try {
// First try to read metadata.json
const metadataPath = path.join(projectPath, 'metadata.json');
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
actualPath = metadata.path || metadata.cwd;
} catch (e) {
// Fallback: try to find the actual path by testing different dash interpretations
let testPath = req.params.projectName;
if (testPath.startsWith('-')) {
testPath = testPath.substring(1);
}
// Try to intelligently decode the path by testing which directories exist
const pathParts = testPath.split('-');
actualPath = '/' + pathParts.join('/');
// If the simple replacement doesn't work, try to find the correct path
// by testing combinations where some dashes might be part of directory names
if (!require('fs').existsSync(actualPath)) {
// Try different combinations of dash vs slash
for (let i = pathParts.length - 1; i >= 0; i--) {
let testParts = [...pathParts];
// Try joining some parts with dashes instead of slashes
for (let j = i; j < testParts.length - 1; j++) {
testParts[j] = testParts[j] + '-' + testParts[j + 1];
testParts.splice(j + 1, 1);
let testActualPath = '/' + testParts.join('/');
if (require('fs').existsSync(testActualPath)) {
actualPath = testActualPath;
break;
}
}
if (require('fs').existsSync(actualPath)) break;
}
}
}
// Check if path exists
try {
await fs.access(actualPath);
} catch (e) {
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
}
const files = await getFileTree(actualPath, 3, 0, true);
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
console.log('📄 Found', files.length, 'files/folders, including', hiddenFiles.length, 'hidden files');
console.log('🔍 Hidden files:', hiddenFiles.map(f => f.name));
res.json(files);
} catch (error) {
console.error('❌ File tree error:', error.message);
res.status(500).json({ error: error.message });
}
});
// WebSocket connection handler that routes based on URL path
wss.on('connection', (ws, request) => {
const url = request.url;
console.log('🔗 Client connected to:', url);
if (url === '/shell') {
handleShellConnection(ws);
} else if (url === '/ws') {
handleChatConnection(ws);
} else {
console.log('❌ Unknown WebSocket path:', url);
ws.close();
}
});
// Handle chat WebSocket connections
function handleChatConnection(ws) {
console.log('💬 Chat WebSocket connected');
// Add to connected clients for project updates
connectedClients.add(ws);
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
if (data.type === 'claude-command') {
console.log('💬 User message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.projectPath || 'Unknown');
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
await spawnClaude(data.command, data.options, ws);
} else if (data.type === 'abort-session') {
console.log('🛑 Abort session request:', data.sessionId);
const success = abortClaudeSession(data.sessionId);
ws.send(JSON.stringify({
type: 'session-aborted',
sessionId: data.sessionId,
success
}));
}
} catch (error) {
console.error('❌ Chat WebSocket error:', error.message);
ws.send(JSON.stringify({
type: 'error',
error: error.message
}));
}
});
ws.on('close', () => {
console.log('🔌 Chat client disconnected');
// Remove from connected clients
connectedClients.delete(ws);
});
}
// Handle shell WebSocket connections
function handleShellConnection(ws) {
console.log('🐚 Shell client connected');
let shellProcess = null;
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
console.log('📨 Shell message received:', data.type);
if (data.type === 'init') {
// Initialize shell with project path and session info
const projectPath = data.projectPath || process.cwd();
const sessionId = data.sessionId;
const hasSession = data.hasSession;
console.log('🚀 Starting shell in:', projectPath);
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : 'New session');
// First send a welcome message
const welcomeMsg = hasSession ?
`\x1b[36mResuming Claude session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
`\x1b[36mStarting new Claude session in: ${projectPath}\x1b[0m\r\n`;
ws.send(JSON.stringify({
type: 'output',
data: welcomeMsg
}));
try {
// Build shell command that changes to project directory first, then runs claude
let claudeCommand = 'claude';
if (hasSession && sessionId) {
// Try to resume session, but with fallback to new session if it fails
claudeCommand = `claude --resume ${sessionId} || claude`;
}
// Create shell command that cds to the project directory first
const shellCommand = `cd "${projectPath}" && ${claudeCommand}`;
console.log('🔧 Executing shell command:', shellCommand);
// Start shell using PTY for proper terminal emulation
shellProcess = pty.spawn('bash', ['-c', shellCommand], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: process.env.HOME || '/', // Start from home directory
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
// Override browser opening commands to echo URL for detection
BROWSER: 'echo "OPEN_URL:"'
}
});
console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);
// Handle data output
shellProcess.onData((data) => {
if (ws.readyState === ws.OPEN) {
let outputData = data;
// Check for various URL opening patterns
const patterns = [
// Direct browser opening commands
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
// BROWSER environment variable override
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
// Git and other tools opening URLs
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
// General URL patterns that might be opened
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
/View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
/Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
];
patterns.forEach(pattern => {
let match;
while ((match = pattern.exec(data)) !== null) {
const url = match[1];
console.log('🔗 Detected URL for opening:', url);
// Send URL opening message to client
ws.send(JSON.stringify({
type: 'url_open',
url: url
}));
// Replace the OPEN_URL pattern with a user-friendly message
if (pattern.source.includes('OPEN_URL')) {
outputData = outputData.replace(match[0], `🌐 Opening in browser: ${url}`);
}
}
});
// Send regular output
ws.send(JSON.stringify({
type: 'output',
data: outputData
}));
}
});
// Handle process exit
shellProcess.onExit((exitCode) => {
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({
type: 'output',
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
}));
}
shellProcess = null;
});
} catch (spawnError) {
console.error('❌ Error spawning process:', spawnError);
ws.send(JSON.stringify({
type: 'output',
data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
}));
}
} else if (data.type === 'input') {
// Send input to shell process
if (shellProcess && shellProcess.write) {
try {
shellProcess.write(data.data);
} catch (error) {
console.error('Error writing to shell:', error);
}
} else {
console.warn('No active shell process to send input to');
}
} else if (data.type === 'resize') {
// Handle terminal resize
if (shellProcess && shellProcess.resize) {
console.log('Terminal resize requested:', data.cols, 'x', data.rows);
shellProcess.resize(data.cols, data.rows);
}
}
} catch (error) {
console.error('❌ Shell WebSocket error:', error.message);
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({
type: 'output',
data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
}));
}
}
});
ws.on('close', () => {
console.log('🔌 Shell client disconnected');
if (shellProcess && shellProcess.kill) {
console.log('🔴 Killing shell process:', shellProcess.pid);
shellProcess.kill();
}
});
ws.on('error', (error) => {
console.error('❌ Shell WebSocket error:', error);
});
}
// Serve React app for all other routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../dist/index.html'));
});
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
const fs = require('fs').promises;
const items = [];
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// Debug: log all entries including hidden files
if (entry.name.startsWith('.')) {
console.log('📁 Found hidden file/folder:', entry.name, 'at depth:', currentDepth);
}
// Skip only heavy build directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build') continue;
const item = {
name: entry.name,
path: path.join(dirPath, entry.name),
type: entry.isDirectory() ? 'directory' : 'file'
};
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth
try {
// Check if we can access the directory before trying to read it
await fs.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
item.children = [];
}
}
items.push(item);
}
} catch (error) {
// Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error);
}
}
return items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
}
const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
setupProjectsWatcher();
});

473
server/projects.js Normal file
View File

@@ -0,0 +1,473 @@
const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
// Load project configuration file
async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
try {
const configData = await fs.readFile(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
// Return empty config if file doesn't exist
return {};
}
}
// Save project configuration file
async function saveProjectConfig(config) {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
}
// Generate better display name from path
function generateDisplayName(projectName) {
// Convert "-home-user-projects-myapp" to a readable format
let path = projectName.replace(/-/g, '/');
// If it starts with /, it's an absolute path
if (path.startsWith('/')) {
const parts = path.split('/').filter(Boolean);
if (parts.length > 3) {
// Show last 2 folders with ellipsis: "...projects/myapp"
return `.../${parts.slice(-2).join('/')}`;
} else {
// Show full path if short: "/home/user"
return path;
}
}
return path;
}
async function getProjects() {
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
const config = await loadProjectConfig();
const projects = [];
const existingProjects = new Set();
try {
// First, get existing projects from the file system
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
existingProjects.add(entry.name);
const projectPath = path.join(claudeDir, entry.name);
// Get display name from config or generate one
const customName = config[entry.name]?.displayName;
const autoDisplayName = generateDisplayName(entry.name);
const fullPath = entry.name.replace(/-/g, '/');
const project = {
name: entry.name,
path: projectPath,
displayName: customName || autoDisplayName,
fullPath: fullPath,
isCustomName: !!customName,
sessions: []
};
// Try to get sessions for this project (just first 5 for performance)
try {
const sessionResult = await getSessions(entry.name, 5, 0);
project.sessions = sessionResult.sessions || [];
project.sessionMeta = {
hasMore: sessionResult.hasMore,
total: sessionResult.total
};
} catch (e) {
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
}
projects.push(project);
}
}
} catch (error) {
console.error('Error reading projects directory:', error);
}
// Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
const fullPath = projectName.replace(/-/g, '/');
const project = {
name: projectName,
path: null, // No physical path yet
displayName: projectConfig.displayName || generateDisplayName(projectName),
fullPath: fullPath,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: []
};
projects.push(project);
}
}
return projects;
}
async function getSessions(projectName, limit = 5, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
return { sessions: [], hasMore: false, total: 0 };
}
// For performance, get file stats to sort by modification time
const filesWithStats = await Promise.all(
jsonlFiles.map(async (file) => {
const filePath = path.join(projectDir, file);
const stats = await fs.stat(filePath);
return { file, mtime: stats.mtime };
})
);
// Sort files by modification time (newest first) for better performance
filesWithStats.sort((a, b) => b.mtime - a.mtime);
const allSessions = new Map();
let processedCount = 0;
// Process files in order of modification time
for (const { file } of filesWithStats) {
const jsonlFile = path.join(projectDir, file);
const sessions = await parseJsonlSessions(jsonlFile);
// Merge sessions, avoiding duplicates by session ID
sessions.forEach(session => {
if (!allSessions.has(session.id)) {
allSessions.set(session.id, session);
}
});
processedCount++;
// Early exit optimization: if we have enough sessions and processed recent files
if (allSessions.size >= (limit + offset) * 2 && processedCount >= Math.min(3, filesWithStats.length)) {
break;
}
}
// Convert to array and sort by last activity
const sortedSessions = Array.from(allSessions.values()).sort((a, b) =>
new Date(b.lastActivity) - new Date(a.lastActivity)
);
const total = sortedSessions.length;
const paginatedSessions = sortedSessions.slice(offset, offset + limit);
const hasMore = offset + limit < total;
return {
sessions: paginatedSessions,
hasMore,
total,
offset,
limit
};
} catch (error) {
console.error(`Error reading sessions for project ${projectName}:`, error);
return { sessions: [], hasMore: false, total: 0 };
}
}
async function parseJsonlSessions(filePath) {
const sessions = new Map();
try {
const fileStream = require('fs').createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
// console.log(`[JSONL Parser] Reading file: ${filePath}`);
let lineCount = 0;
for await (const line of rl) {
if (line.trim()) {
lineCount++;
try {
const entry = JSON.parse(line);
if (entry.sessionId) {
if (!sessions.has(entry.sessionId)) {
sessions.set(entry.sessionId, {
id: entry.sessionId,
summary: 'New Session',
messageCount: 0,
lastActivity: new Date(),
cwd: entry.cwd || ''
});
}
const session = sessions.get(entry.sessionId);
// Update summary if this is a summary entry
if (entry.type === 'summary' && entry.summary) {
session.summary = entry.summary;
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
// Use first user message as summary if no summary entry exists
const content = entry.message.content;
if (typeof content === 'string' && content.length > 0) {
// Skip command messages that start with <command-name>
if (!content.startsWith('<command-name>')) {
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
}
}
}
// Count messages instead of storing them all
session.messageCount = (session.messageCount || 0) + 1;
// Update last activity
if (entry.timestamp) {
session.lastActivity = new Date(entry.timestamp);
}
}
} catch (parseError) {
console.warn(`[JSONL Parser] Error parsing line ${lineCount}:`, parseError.message);
}
}
}
// console.log(`[JSONL Parser] Processed ${lineCount} lines, found ${sessions.size} sessions`);
} catch (error) {
console.error('Error reading JSONL file:', error);
}
// Convert Map to Array and sort by last activity
return Array.from(sessions.values()).sort((a, b) =>
new Date(b.lastActivity) - new Date(a.lastActivity)
);
}
// Get messages for a specific session
async function getSessionMessages(projectName, sessionId) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
return [];
}
const messages = [];
// Process all JSONL files to find messages for this session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = require('fs').createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
if (entry.sessionId === sessionId) {
messages.push(entry);
}
} catch (parseError) {
console.warn('Error parsing line:', parseError.message);
}
}
}
}
// Sort messages by timestamp
return messages.sort((a, b) =>
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
);
} catch (error) {
console.error(`Error reading messages for session ${sessionId}:`, error);
return [];
}
}
// Rename a project's display name
async function renameProject(projectName, newDisplayName) {
const config = await loadProjectConfig();
if (!newDisplayName || newDisplayName.trim() === '') {
// Remove custom name if empty, will fall back to auto-generated
delete config[projectName];
} else {
// Set custom display name
config[projectName] = {
displayName: newDisplayName.trim()
};
}
await saveProjectConfig(config);
return true;
}
// Delete a session from a project
async function deleteSession(projectName, sessionId) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
throw new Error('No session files found for this project');
}
// Check all JSONL files to find which one contains the session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const content = await fs.readFile(jsonlFile, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
// Check if this file contains the session
const hasSession = lines.some(line => {
try {
const data = JSON.parse(line);
return data.sessionId === sessionId;
} catch {
return false;
}
});
if (hasSession) {
// Filter out all entries for this session
const filteredLines = lines.filter(line => {
try {
const data = JSON.parse(line);
return data.sessionId !== sessionId;
} catch {
return true; // Keep malformed lines
}
});
// Write back the filtered content
await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
return true;
}
}
throw new Error(`Session ${sessionId} not found in any files`);
} catch (error) {
console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
throw error;
}
}
// Check if a project is empty (has no sessions)
async function isProjectEmpty(projectName) {
try {
const sessionsResult = await getSessions(projectName, 1, 0);
return sessionsResult.total === 0;
} catch (error) {
console.error(`Error checking if project ${projectName} is empty:`, error);
return false;
}
}
// Delete an empty project
async function deleteProject(projectName) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) {
throw new Error('Cannot delete project with existing sessions');
}
// Remove the project directory
await fs.rm(projectDir, { recursive: true, force: true });
// Remove from project config
const config = await loadProjectConfig();
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
throw error;
}
}
// Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath);
try {
// Check if the path exists
await fs.access(absolutePath);
} catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
// Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-');
// Check if project already exists in config or as a folder
const config = await loadProjectConfig();
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
await fs.access(projectDir);
throw new Error(`Project already exists for path: ${absolutePath}`);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
if (config[projectName]) {
throw new Error(`Project already configured for path: ${absolutePath}`);
}
// Add to config as manually added project
config[projectName] = {
manuallyAdded: true,
originalPath: absolutePath
};
if (displayName) {
config[projectName].displayName = displayName;
}
await saveProjectConfig(config);
return {
name: projectName,
path: null,
fullPath: absolutePath,
displayName: displayName || generateDisplayName(projectName),
isManuallyAdded: true,
sessions: []
};
}
module.exports = {
getProjects,
getSessions,
getSessionMessages,
parseJsonlSessions,
renameProject,
deleteSession,
isProjectEmpty,
deleteProject,
addProjectManually,
loadProjectConfig,
saveProjectConfig
};

491
src/App.jsx Normal file
View File

@@ -0,0 +1,491 @@
/*
* App.jsx - Main Application Component with Session Protection System
*
* SESSION PROTECTION SYSTEM OVERVIEW:
* ===================================
*
* Problem: Automatic project updates from WebSocket would refresh the sidebar and clear chat messages
* during active conversations, creating a poor user experience.
*
* Solution: Track "active sessions" and pause project updates during conversations.
*
* How it works:
* 1. When user sends message → session marked as "active"
* 2. Project updates are skipped while session is active
* 3. When conversation completes/aborts → session marked as "inactive"
* 4. Project updates resume normally
*
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs).
*/
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import Sidebar from './components/Sidebar';
import MainContent from './components/MainContent';
import MobileNav from './components/MobileNav';
import ToolsSettings from './components/ToolsSettings';
import { useWebSocket } from './utils/websocket';
// Main App component with routing
function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState(null);
const [selectedSession, setSelectedSession] = useState(null);
const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files'
const [isMobile, setIsMobile] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [isInputFocused, setIsInputFocused] = useState(false);
const [showToolsSettings, setShowToolsSettings] = useState(false);
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
// until the conversation completes or is aborted.
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
const { ws, sendMessage, messages } = useWebSocket();
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
// Fetch projects on component mount
fetchProjects();
}, []);
// Helper function to determine if an update is purely additive (new sessions/projects)
// vs modifying existing selected items that would interfere with active conversations
const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => {
if (!selectedProject || !selectedSession) {
// No active session to protect, allow all updates
return true;
}
// Find the selected project in both current and updated data
const currentSelectedProject = currentProjects?.find(p => p.name === selectedProject.name);
const updatedSelectedProject = updatedProjects?.find(p => p.name === selectedProject.name);
if (!currentSelectedProject || !updatedSelectedProject) {
// Project structure changed significantly, not purely additive
return false;
}
// Find the selected session in both current and updated project data
const currentSelectedSession = currentSelectedProject.sessions?.find(s => s.id === selectedSession.id);
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
if (!currentSelectedSession || !updatedSelectedSession) {
// Selected session was deleted or significantly changed, not purely additive
return false;
}
// Check if the selected session's content has changed (modification vs addition)
// Compare key fields that would affect the loaded chat interface
const sessionUnchanged =
currentSelectedSession.id === updatedSelectedSession.id &&
currentSelectedSession.title === updatedSelectedSession.title &&
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
currentSelectedSession.updated_at === updatedSelectedSession.updated_at;
// This is considered additive if the selected session is unchanged
// (new sessions may have been added elsewhere, but active session is protected)
return sessionUnchanged;
};
// Handle WebSocket messages for real-time project updates
useEffect(() => {
if (messages.length > 0) {
const latestMessage = messages[messages.length - 1];
if (latestMessage.type === 'projects_updated') {
// Session Protection Logic: Allow additions but prevent changes during active conversations
// This allows new sessions/projects to appear in sidebar while protecting active chat messages
// We check for two types of active sessions:
// 1. Existing sessions: selectedSession.id exists in activeSessions
// 2. New sessions: temporary "new-session-*" identifiers in activeSessions (before real session ID is received)
const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) ||
(activeSessions.size > 0 && Array.from(activeSessions).some(id => id.startsWith('new-session-')));
if (hasActiveSession) {
// Allow updates but be selective: permit additions, prevent changes to existing items
const updatedProjects = latestMessage.projects;
const currentProjects = projects;
// Check if this is purely additive (new sessions/projects) vs modification of existing ones
const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession);
if (!isAdditiveUpdate) {
// Skip updates that would modify existing selected session/project
return;
}
// Continue with additive updates below
}
// Update projects state with the new data from WebSocket
const updatedProjects = latestMessage.projects;
setProjects(updatedProjects);
// Update selected project if it exists in the updated projects
if (selectedProject) {
const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name);
if (updatedSelectedProject) {
setSelectedProject(updatedSelectedProject);
// Update selected session only if it was deleted - avoid unnecessary reloads
if (selectedSession) {
const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id);
if (!updatedSelectedSession) {
// Session was deleted
setSelectedSession(null);
}
// Don't update if session still exists with same ID - prevents reload
}
}
}
}
}
}, [messages, selectedProject, selectedSession, activeSessions]);
const fetchProjects = async () => {
try {
setIsLoadingProjects(true);
const response = await fetch('/api/projects');
const data = await response.json();
// Optimize to preserve object references when data hasn't changed
setProjects(prevProjects => {
// If no previous projects, just set the new data
if (prevProjects.length === 0) {
return data;
}
// Check if the projects data has actually changed
const hasChanges = data.some((newProject, index) => {
const prevProject = prevProjects[index];
if (!prevProject) return true;
// Compare key properties that would affect UI
return (
newProject.name !== prevProject.name ||
newProject.displayName !== prevProject.displayName ||
newProject.fullPath !== prevProject.fullPath ||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions)
);
}) || data.length !== prevProjects.length;
// Only update if there are actual changes
return hasChanges ? data : prevProjects;
});
// Don't auto-select any project - user should choose manually
} catch (error) {
console.error('Error fetching projects:', error);
} finally {
setIsLoadingProjects(false);
}
};
// Expose fetchProjects globally for component access
window.refreshProjects = fetchProjects;
// Handle URL-based session loading
useEffect(() => {
if (sessionId && projects.length > 0) {
// Find the session across all projects
for (const project of projects) {
const session = project.sessions?.find(s => s.id === sessionId);
if (session) {
setSelectedProject(project);
setSelectedSession(session);
setActiveTab('chat');
return;
}
}
// If session not found, it might be a newly created session
// Just navigate to it and it will be found when the sidebar refreshes
// Don't redirect to home, let the session load naturally
}
}, [sessionId, projects, navigate]);
const handleProjectSelect = (project) => {
setSelectedProject(project);
setSelectedSession(null);
navigate('/');
if (isMobile) {
setSidebarOpen(false);
}
};
const handleSessionSelect = (session) => {
setSelectedSession(session);
setActiveTab('chat');
if (isMobile) {
setSidebarOpen(false);
}
navigate(`/session/${session.id}`);
};
const handleNewSession = (project) => {
setSelectedProject(project);
setSelectedSession(null);
setActiveTab('chat');
navigate('/');
if (isMobile) {
setSidebarOpen(false);
}
};
const handleSessionDelete = (sessionId) => {
// If the deleted session was currently selected, clear it
if (selectedSession?.id === sessionId) {
setSelectedSession(null);
navigate('/');
}
// Update projects state locally instead of full refresh
setProjects(prevProjects =>
prevProjects.map(project => ({
...project,
sessions: project.sessions?.filter(session => session.id !== sessionId) || [],
sessionMeta: {
...project.sessionMeta,
total: Math.max(0, (project.sessionMeta?.total || 0) - 1)
}
}))
);
};
const handleSidebarRefresh = async () => {
// Refresh only the sessions for all projects, don't change selected state
try {
const response = await fetch('/api/projects');
const freshProjects = await response.json();
// Optimize to preserve object references and minimize re-renders
setProjects(prevProjects => {
// Check if projects data has actually changed
const hasChanges = freshProjects.some((newProject, index) => {
const prevProject = prevProjects[index];
if (!prevProject) return true;
return (
newProject.name !== prevProject.name ||
newProject.displayName !== prevProject.displayName ||
newProject.fullPath !== prevProject.fullPath ||
JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) ||
JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions)
);
}) || freshProjects.length !== prevProjects.length;
return hasChanges ? freshProjects : prevProjects;
});
// If we have a selected project, make sure it's still selected after refresh
if (selectedProject) {
const refreshedProject = freshProjects.find(p => p.name === selectedProject.name);
if (refreshedProject) {
// Only update selected project if it actually changed
if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) {
setSelectedProject(refreshedProject);
}
// If we have a selected session, try to find it in the refreshed project
if (selectedSession) {
const refreshedSession = refreshedProject.sessions?.find(s => s.id === selectedSession.id);
if (refreshedSession && JSON.stringify(refreshedSession) !== JSON.stringify(selectedSession)) {
setSelectedSession(refreshedSession);
}
}
}
}
} catch (error) {
console.error('Error refreshing sidebar:', error);
}
};
const handleProjectDelete = (projectName) => {
// If the deleted project was currently selected, clear it
if (selectedProject?.name === projectName) {
setSelectedProject(null);
setSelectedSession(null);
navigate('/');
}
// Update projects state locally instead of full refresh
setProjects(prevProjects =>
prevProjects.filter(project => project.name !== projectName)
);
};
// Session Protection Functions: Manage the lifecycle of active sessions
// markSessionAsActive: Called when user sends a message to mark session as protected
// This includes both real session IDs and temporary "new-session-*" identifiers
const markSessionAsActive = (sessionId) => {
if (sessionId) {
setActiveSessions(prev => new Set([...prev, sessionId]));
}
};
// markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates
const markSessionAsInactive = (sessionId) => {
if (sessionId) {
setActiveSessions(prev => {
const newSet = new Set(prev);
newSet.delete(sessionId);
return newSet;
});
}
};
// replaceTemporarySession: Called when WebSocket provides real session ID for new sessions
// Removes temporary "new-session-*" identifiers and adds the real session ID
// This maintains protection continuity during the transition from temporary to real session
const replaceTemporarySession = (realSessionId) => {
if (realSessionId) {
setActiveSessions(prev => {
const newSet = new Set();
// Keep all non-temporary sessions and add the real session ID
for (const sessionId of prev) {
if (!sessionId.startsWith('new-session-')) {
newSet.add(sessionId);
}
}
newSet.add(realSessionId);
return newSet;
});
}
};
return (
<div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */}
{!isMobile && (
<div className="w-80 flex-shrink-0 border-r border-border bg-card">
<div className="h-full overflow-hidden">
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)}
/>
</div>
</div>
)}
{/* Mobile Sidebar Overlay */}
{isMobile && (
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
}`}>
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
onClick={(e) => {
e.stopPropagation();
setSidebarOpen(false);
}}
onTouchStart={(e) => {
e.preventDefault();
e.stopPropagation();
setSidebarOpen(false);
}}
/>
<div
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border h-full transform transition-transform duration-150 ease-out ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)}
/>
</div>
</div>
)}
{/* Main Content Area - Flexible */}
<div className="flex-1 flex flex-col min-w-0">
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}
activeTab={activeTab}
setActiveTab={setActiveTab}
ws={ws}
sendMessage={sendMessage}
messages={messages}
isMobile={isMobile}
onMenuClick={() => setSidebarOpen(true)}
isLoading={isLoadingProjects}
onInputFocusChange={setIsInputFocused}
onSessionActive={markSessionAsActive}
onSessionInactive={markSessionAsInactive}
onReplaceTemporarySession={replaceTemporarySession}
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
/>
</div>
{/* Mobile Bottom Navigation */}
{isMobile && (
<MobileNav
activeTab={activeTab}
setActiveTab={setActiveTab}
isInputFocused={isInputFocused}
/>
)}
{/* Tools Settings Modal */}
<ToolsSettings
isOpen={showToolsSettings}
onClose={() => setShowToolsSettings(false)}
/>
</div>
);
}
// Root App component with router
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
);
}
export default App;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,428 @@
import React, { useState, useEffect, useRef } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, Decoration } from '@codemirror/view';
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
function CodeEditor({ file, onClose, projectPath }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
// Create diff highlighting
const diffEffect = StateEffect.define();
const diffField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (let effect of tr.effects) {
if (effect.is(diffEffect)) {
decorations = effect.value;
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
});
const createDiffDecorations = (content, diffInfo) => {
if (!diffInfo || !showDiff) return Decoration.none;
const builder = new RangeSetBuilder();
const lines = content.split('\n');
const oldLines = diffInfo.old_string.split('\n');
// Find the line where the old content starts
let startLineIndex = -1;
for (let i = 0; i <= lines.length - oldLines.length; i++) {
let matches = true;
for (let j = 0; j < oldLines.length; j++) {
if (lines[i + j] !== oldLines[j]) {
matches = false;
break;
}
}
if (matches) {
startLineIndex = i;
break;
}
}
if (startLineIndex >= 0) {
let pos = 0;
// Calculate position to start of old content
for (let i = 0; i < startLineIndex; i++) {
pos += lines[i].length + 1; // +1 for newline
}
// Highlight old lines (to be removed)
for (let i = 0; i < oldLines.length; i++) {
const lineStart = pos;
const lineEnd = pos + oldLines[i].length;
builder.add(lineStart, lineEnd, Decoration.line({
class: isDarkMode ? 'diff-removed-dark' : 'diff-removed-light'
}));
pos += oldLines[i].length + 1;
}
}
return builder.finish();
};
// Diff decoration theme
const diffTheme = EditorView.theme({
'.diff-removed-light': {
backgroundColor: '#fef2f2',
borderLeft: '3px solid #ef4444'
},
'.diff-removed-dark': {
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderLeft: '3px solid #ef4444'
},
'.diff-added-light': {
backgroundColor: '#f0fdf4',
borderLeft: '3px solid #22c55e'
},
'.diff-added-dark': {
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderLeft: '3px solid #22c55e'
}
});
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
default:
return [];
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
const response = await fetch(`/api/projects/${file.projectName}/file?filePath=${encodeURIComponent(file.path)}`);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
console.error('Error loading file:', error);
setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [file, projectPath]);
// Update diff decorations when content or diff info changes
const editorRef = useRef(null);
useEffect(() => {
if (editorRef.current && content && file.diffInfo && showDiff) {
const decorations = createDiffDecorations(content, file.diffInfo);
const view = editorRef.current.view;
if (view) {
view.dispatch({
effects: diffEffect.of(decorations)
});
}
}
}, [content, file.diffInfo, showDiff, isDarkMode]);
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch(`/api/projects/${file.projectName}/file`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filePath: file.path,
content: content
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
}
const result = await response.json();
// Show success feedback
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds
} catch (error) {
console.error('Error saving file:', error);
alert(`Error saving file: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content]);
if (loading) {
return (
<>
<style>
{`
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
</>
);
}
return (
<>
<style>
{`
.code-editor-modal {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-modal:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`code-editor-modal shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-mono">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded whitespace-nowrap">
📝 Has changes
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
{file.diffInfo && (
<button
onClick={() => setShowDiff(!showDiff)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={showDiff ? "Hide diff highlighting" : "Show diff highlighting"}
>
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
</button>
)}
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Toggle theme"
>
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Download file"
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{saveSuccess ? (
<>
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="hidden sm:inline">Saved!</span>
</>
) : (
<>
<Save className="w-5 h-5 md:w-4 md:h-4" />
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
</>
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Close"
>
<X className="w-6 h-6 md:w-4 md:h-4" />
</button>
</div>
</div>
{/* Editor */}
<div className="flex-1 overflow-hidden">
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
diffField,
diffTheme
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: '14px',
height: '100%',
}}
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
</div>
</div>
</div>
</div>
</>
);
}
export default CodeEditor;

188
src/components/FileTree.jsx Normal file
View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
import { cn } from '../lib/utils';
import CodeEditor from './CodeEditor';
import ImageViewer from './ImageViewer';
function FileTree({ selectedProject }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedFile, setSelectedFile] = useState(null);
const [selectedImage, setSelectedImage] = useState(null);
useEffect(() => {
if (selectedProject) {
fetchFiles();
}
}, [selectedProject]);
const fetchFiles = async () => {
setLoading(true);
try {
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ File fetch failed:', response.status, errorText);
setFiles([]);
return;
}
const data = await response.json();
setFiles(data);
} catch (error) {
console.error('❌ Error fetching files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const toggleDirectory = (path) => {
const newExpanded = new Set(expandedDirs);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedDirs(newExpanded);
};
const renderFileTree = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<Button
variant="ghost"
className={cn(
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent",
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
// Open image in viewer
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
// Open file in editor
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="flex items-center gap-2 min-w-0 w-full">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
</Button>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
item.children.length > 0 && (
<div>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
));
};
const isImageFile = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
return imageExtensions.includes(ext);
};
const getFileIcon = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const codeExtensions = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'cpp', 'c', 'php', 'rb', 'go', 'rs'];
const docExtensions = ['md', 'txt', 'doc', 'pdf'];
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
if (codeExtensions.includes(ext)) {
return <FileCode className="w-4 h-4 text-green-500 flex-shrink-0" />;
} else if (docExtensions.includes(ext)) {
return <FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />;
} else if (imageExtensions.includes(ext)) {
return <File className="w-4 h-4 text-purple-500 flex-shrink-0" />;
} else {
return <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />;
}
};
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400">
Loading files...
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-card">
<ScrollArea className="flex-1 p-4">
{files.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">No files found</h4>
<p className="text-sm text-muted-foreground">
Check if the project path is accessible
</p>
</div>
) : (
<div className="space-y-1">
{renderFileTree(files)}
</div>
)}
</ScrollArea>
{/* Code Editor Modal */}
{selectedFile && (
<CodeEditor
file={selectedFile}
onClose={() => setSelectedFile(null)}
projectPath={selectedFile.projectPath}
/>
)}
{/* Image Viewer Modal */}
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}
export default FileTree;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Button } from './ui/button';
import { X } from 'lucide-react';
function ImageViewer({ file, onClose }) {
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{file.name}
</h3>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
<img
src={imagePath}
alt={file.name}
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'block';
}}
/>
<div
className="text-center text-gray-500 dark:text-gray-400"
style={{ display: 'none' }}
>
<p>Unable to load image</p>
<p className="text-sm mt-2">{file.path}</p>
</div>
</div>
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400">
{file.path}
</p>
</div>
</div>
</div>
);
}
export default ImageViewer;

View File

@@ -0,0 +1,268 @@
/*
* MainContent.jsx - Main Content Area with Session Protection Props Passthrough
*
* SESSION PROTECTION PASSTHROUGH:
* ===============================
*
* This component serves as a passthrough layer for Session Protection functions:
* - Receives session management functions from App.jsx
* - Passes them down to ChatInterface.jsx
*
* No session protection logic is implemented here - it's purely a props bridge.
*/
import React, { useState } from 'react';
import ChatInterface from './ChatInterface';
import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
import Shell from './Shell';
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
messages,
isMobile,
onMenuClick,
isLoading,
onInputFocusChange,
// Session Protection Props: Functions passed down from App.jsx to manage active session state
// These functions control when project updates are paused during active conversations
onSessionActive, // Mark session as active when user sends message
onSessionInactive, // Mark session as inactive when conversation completes/aborts
onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket
onNavigateToSession // Navigate to a specific session (for Claude CLI session duplication workaround)
}) {
const [editingFile, setEditingFile] = useState(null);
const handleFileOpen = (filePath, diffInfo = null) => {
// Create a file object that CodeEditor expects
const file = {
name: filePath.split('/').pop(),
path: filePath,
projectName: selectedProject?.name,
diffInfo: diffInfo // Pass along diff information if available
};
setEditingFile(file);
};
const handleCloseEditor = () => {
setEditingFile(null);
};
if (isLoading) {
return (
<div className="h-full flex flex-col">
{/* Header with menu button for mobile */}
{isMobile && (
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
<button
onClick={onMenuClick}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4">
<div className="w-16 h-16 animate-spin rounded-full border-4 border-gray-300 dark:border-gray-600 border-t-blue-600" />
</div>
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2>
<p>Setting up your workspace...</p>
</div>
</div>
</div>
);
}
if (!selectedProject) {
return (
<div className="h-full flex flex-col">
{/* Header with menu button for mobile */}
{isMobile && (
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
<button
onClick={onMenuClick}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400 max-w-md mx-auto px-6">
<div className="w-16 h-16 mx-auto mb-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">Choose Your Project</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.
</p>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-700 dark:text-blue-300">
💡 <strong>Tip:</strong> {isMobile ? 'Tap the menu button above to access projects' : 'Create a new project by clicking the folder icon in the sidebar'}
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 sm:space-x-3">
{isMobile && (
<button
onClick={onMenuClick}
onTouchStart={(e) => {
e.preventDefault();
onMenuClick();
}}
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 transition-transform duration-75"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="min-w-0">
{activeTab === 'chat' && selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate">
{selectedSession.summary}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName} <span className="hidden sm:inline"> {selectedSession.id}</span>
</div>
</div>
) : activeTab === 'chat' && !selectedSession ? (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
New Session
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
) : (
<div>
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
Project Files
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
)}
</div>
</div>
{/* Modern Tab Navigation - Right Side */}
<div className="flex-shrink-0 hidden sm:block">
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setActiveTab('chat')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'chat'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden sm:inline">Chat</span>
</span>
</button>
<button
onClick={() => setActiveTab('shell')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'shell'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="hidden sm:inline">Shell</span>
</span>
</button>
<button
onClick={() => setActiveTab('files')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'files'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="hidden sm:inline">Files</span>
</span>
</button>
</div>
</div>
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
/>
</div>
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
<FileTree selectedProject={selectedProject} />
</div>
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
<Shell
selectedProject={selectedProject}
selectedSession={selectedSession}
isActive={activeTab === 'shell'}
/>
</div>
</div>
{/* Code Editor Modal */}
{editingFile && (
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
/>
)}
</div>
);
}
export default React.memo(MainContent);

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { MessageSquare, Folder, Terminal } from 'lucide-react';
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
// Detect dark mode
const isDarkMode = document.documentElement.classList.contains('dark');
const navItems = [
{
id: 'chat',
icon: MessageSquare,
onClick: () => setActiveTab('chat')
},
{
id: 'shell',
icon: Terminal,
onClick: () => setActiveTab('shell')
},
{
id: 'files',
icon: Folder,
onClick: () => setActiveTab('files')
}
];
return (
<>
<style>
{`
.mobile-nav-container {
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
}
.mobile-nav-container:hover {
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'} !important;
}
`}
</style>
<div
className={`mobile-nav-container fixed bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${
isInputFocused ? 'translate-y-full' : 'translate-y-0'
}`}
>
<div className="flex items-center justify-around py-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
}}
className={`flex items-center justify-center p-2 rounded-lg transition-colors duration-75 min-h-[40px] min-w-[40px] relative touch-manipulation ${
isActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
aria-label={item.id}
>
<Icon className="w-5 h-5" />
{isActive && (
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" />
)}
</button>
);
})}
</div>
</div>
</>
);
}
export default MobileNav;

537
src/components/Shell.jsx Normal file
View File

@@ -0,0 +1,537 @@
import React, { useEffect, useRef, useState } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { WebglAddon } from '@xterm/addon-webgl';
import 'xterm/css/xterm.css';
// Global store for shell sessions to persist across tab switches
const shellSessions = new Map();
function Shell({ selectedProject, selectedSession, isActive }) {
const terminalRef = useRef(null);
const terminal = useRef(null);
const fitAddon = useRef(null);
const ws = useRef(null);
const [isConnected, setIsConnected] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [lastSessionId, setLastSessionId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false);
// Connect to shell function
const connectToShell = () => {
if (!isInitialized || isConnected || isConnecting) return;
setIsConnecting(true);
// Start the WebSocket connection
connectWebSocket();
};
// Disconnect from shell function
const disconnectFromShell = () => {
if (ws.current) {
ws.current.close();
ws.current = null;
}
// Clear terminal content completely
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
}
setIsConnected(false);
setIsConnecting(false);
};
// Restart shell function
const restartShell = () => {
setIsRestarting(true);
// Clear ALL session storage for this project to force fresh start
const sessionKeys = Array.from(shellSessions.keys()).filter(key =>
key.includes(selectedProject.name)
);
sessionKeys.forEach(key => shellSessions.delete(key));
// Close existing WebSocket
if (ws.current) {
ws.current.close();
ws.current = null;
}
// Clear and dispose existing terminal
if (terminal.current) {
// Dispose terminal immediately without writing text
terminal.current.dispose();
terminal.current = null;
fitAddon.current = null;
}
// Reset states
setIsConnected(false);
setIsInitialized(false);
// Force re-initialization after cleanup
setTimeout(() => {
setIsRestarting(false);
}, 200);
};
// Watch for session changes and restart shell
useEffect(() => {
const currentSessionId = selectedSession?.id || null;
// Disconnect when session changes (user will need to manually reconnect)
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
// Disconnect from current shell
disconnectFromShell();
// Clear stored sessions for this project
const allKeys = Array.from(shellSessions.keys());
allKeys.forEach(key => {
if (key.includes(selectedProject.name)) {
shellSessions.delete(key);
}
});
}
setLastSessionId(currentSessionId);
}, [selectedSession?.id, isInitialized]);
// Initialize terminal when component mounts
useEffect(() => {
if (!terminalRef.current || !selectedProject || isRestarting) {
return;
}
// Create session key for this project/session combination
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
// Check if we have an existing session
const existingSession = shellSessions.get(sessionKey);
if (existingSession && !terminal.current) {
try {
// Reuse existing terminal
terminal.current = existingSession.terminal;
fitAddon.current = existingSession.fitAddon;
ws.current = existingSession.ws;
setIsConnected(existingSession.isConnected);
// Reattach to DOM - dispose existing element first if needed
if (terminal.current.element && terminal.current.element.parentNode) {
terminal.current.element.parentNode.removeChild(terminal.current.element);
}
terminal.current.open(terminalRef.current);
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 100);
setIsInitialized(true);
return;
} catch (error) {
// Clear the broken session and continue to create a new one
shellSessions.delete(sessionKey);
terminal.current = null;
fitAddon.current = null;
ws.current = null;
}
}
if (terminal.current) {
return;
}
// Initialize new terminal
terminal.current = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true, // Required for clipboard addon
allowTransparency: false,
convertEol: true,
scrollback: 10000,
tabStopWidth: 4,
// Enable full color support
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: false,
// Enhanced theme with full 16-color ANSI support + true colors
theme: {
// Basic colors
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
selectionForeground: '#ffffff',
// Standard ANSI colors (0-7)
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
// Bright ANSI colors (8-15)
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
// Extended colors for better Claude output
extendedAnsi: [
// 16-color palette extension for 256-color support
'#000000', '#800000', '#008000', '#808000',
'#000080', '#800080', '#008080', '#c0c0c0',
'#808080', '#ff0000', '#00ff00', '#ffff00',
'#0000ff', '#ff00ff', '#00ffff', '#ffffff'
]
}
});
fitAddon.current = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const webglAddon = new WebglAddon();
terminal.current.loadAddon(fitAddon.current);
terminal.current.loadAddon(clipboardAddon);
try {
terminal.current.loadAddon(webglAddon);
} catch (error) {
}
terminal.current.open(terminalRef.current);
// Add keyboard shortcuts for copy/paste
terminal.current.attachCustomKeyEventHandler((event) => {
// Ctrl+C or Cmd+C for copy (when text is selected)
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
document.execCommand('copy');
return false;
}
// Ctrl+V or Cmd+V for paste
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
navigator.clipboard.readText().then(text => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: text
}));
}
}).catch(err => {
// Failed to read clipboard
});
return false;
}
return true;
});
// Ensure terminal takes full space
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 100);
setIsInitialized(true);
// Handle terminal input
terminal.current.onData((data) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
// Add resize observer to handle container size changes
const resizeObserver = new ResizeObserver(() => {
if (fitAddon.current && terminal.current) {
setTimeout(() => {
fitAddon.current.fit();
}, 50);
}
});
if (terminalRef.current) {
resizeObserver.observe(terminalRef.current);
}
return () => {
resizeObserver.disconnect();
// Store session for reuse instead of disposing
if (terminal.current && selectedProject) {
const sessionKey = selectedSession?.id || `project-${selectedProject.name}`;
try {
shellSessions.set(sessionKey, {
terminal: terminal.current,
fitAddon: fitAddon.current,
ws: ws.current,
isConnected: isConnected
});
} catch (error) {
}
}
};
}, [terminalRef.current, selectedProject, selectedSession, isRestarting]);
// Fit terminal when tab becomes active
useEffect(() => {
if (!isActive || !isInitialized) return;
// Fit terminal when tab becomes active
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
}
}, 100);
}, [isActive, isInitialized]);
// WebSocket connection function (called manually)
const connectWebSocket = async () => {
if (isConnecting || isConnected) return;
try {
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
// If the config returns localhost but we're not on localhost, use current host but with API server port
if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
} catch (error) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/shell`;
ws.current = new WebSocket(wsUrl);
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
// Send initial setup with project path and session info
const initPayload = {
type: 'init',
projectPath: selectedProject.fullPath || selectedProject.path,
sessionId: selectedSession?.id,
hasSession: !!selectedSession
};
ws.current.send(JSON.stringify(initPayload));
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
// Check for URLs in the output and make them clickable
const urlRegex = /(https?:\/\/[^\s\x1b\x07]+)/g;
let output = data.data;
// Find URLs in the text (excluding ANSI escape sequences)
const urls = [];
let match;
while ((match = urlRegex.exec(output.replace(/\x1b\[[0-9;]*m/g, ''))) !== null) {
urls.push(match[1]);
}
// If URLs found, log them for potential opening
terminal.current.write(output);
} else if (data.type === 'url_open') {
// Handle explicit URL opening requests from server
window.open(data.url, '_blank');
}
} catch (error) {
}
};
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
// Clear terminal content when connection closes
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to home
}
// Don't auto-reconnect anymore - user must manually connect
};
ws.current.onerror = (error) => {
setIsConnected(false);
setIsConnecting(false);
};
} catch (error) {
setIsConnected(false);
setIsConnecting(false);
}
};
if (!selectedProject) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
<p>Choose a project to open an interactive shell in that directory</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-gray-900">
{/* Header */}
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({selectedSession.summary.slice(0, 30)}...)
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">(Initializing...)</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">(Restarting...)</span>
)}
</div>
<div className="flex items-center space-x-3">
{isConnected && (
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title="Disconnect from shell"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Disconnect</span>
</button>
)}
<button
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Restart Shell (disconnect first)"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Restart</span>
</button>
</div>
</div>
</div>
{/* Terminal */}
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full" />
{/* Loading state */}
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div>
</div>
)}
{/* Connect button when not connected */}
{isInitialized && !isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-center">
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2 text-base font-medium"
title="Connect to shell"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Continue in Shell</span>
</button>
<p className="text-gray-400 text-sm mt-3">
{selectedSession ?
`Resume session: ${selectedSession.summary.slice(0, 50)}...` :
'Start a new Claude session'
}
</p>
</div>
</div>
)}
{/* Connecting state */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-center">
<div className="flex items-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">Connecting to shell...</span>
</div>
<p className="text-gray-400 text-sm mt-3">
Starting Claude CLI in {selectedProject.displayName}
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default Shell;

974
src/components/Sidebar.jsx Normal file
View File

@@ -0,0 +1,974 @@
import React, { useState, useEffect } from 'react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw } from 'lucide-react';
import { cn } from '../lib/utils';
// Move formatTimeAgo outside component to avoid recreation on every render
const formatTimeAgo = (dateString, currentTime) => {
const date = new Date(dateString);
const now = currentTime;
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Unknown';
}
const diffInMs = now - date;
const diffInSeconds = Math.floor(diffInMs / 1000);
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInSeconds < 60) return 'Just now';
if (diffInMinutes === 1) return '1 min ago';
if (diffInMinutes < 60) return `${diffInMinutes} mins ago`;
if (diffInHours === 1) return '1 hour ago';
if (diffInHours < 24) return `${diffInHours} hours ago`;
if (diffInDays === 1) return '1 day ago';
if (diffInDays < 7) return `${diffInDays} days ago`;
return date.toLocaleDateString();
};
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
onNewSession,
onSessionDelete,
onProjectDelete,
isLoading,
onRefresh,
onShowSettings
}) {
const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null);
const [showNewProject, setShowNewProject] = useState(false);
const [editingName, setEditingName] = useState('');
const [newProjectPath, setNewProjectPath] = useState('');
const [creatingProject, setCreatingProject] = useState(false);
const [loadingSessions, setLoadingSessions] = useState({});
const [additionalSessions, setAdditionalSessions] = useState({});
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
const [currentTime, setCurrentTime] = useState(new Date());
const [isRefreshing, setIsRefreshing] = useState(false);
// Touch handler to prevent double-tap issues on iPad
const handleTouchClick = (callback) => {
return (e) => {
e.preventDefault();
e.stopPropagation();
callback();
};
};
// Auto-update timestamps every minute
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 60000); // Update every 60 seconds
return () => clearInterval(timer);
}, []);
// Clear additional sessions when projects list changes (e.g., after refresh)
useEffect(() => {
setAdditionalSessions({});
setInitialSessionsLoaded(new Set());
}, [projects]);
// Auto-expand project folder when a session is selected
useEffect(() => {
if (selectedSession && selectedProject) {
setExpandedProjects(prev => new Set([...prev, selectedProject.name]));
}
}, [selectedSession, selectedProject]);
// Mark sessions as loaded when projects come in
useEffect(() => {
if (projects.length > 0 && !isLoading) {
const newLoaded = new Set();
projects.forEach(project => {
if (project.sessions && project.sessions.length >= 0) {
newLoaded.add(project.name);
}
});
setInitialSessionsLoaded(newLoaded);
}
}, [projects, isLoading]);
const toggleProject = (projectName) => {
const newExpanded = new Set(expandedProjects);
if (newExpanded.has(projectName)) {
newExpanded.delete(projectName);
} else {
newExpanded.add(projectName);
}
setExpandedProjects(newExpanded);
};
const startEditing = (project) => {
setEditingProject(project.name);
setEditingName(project.displayName);
};
const cancelEditing = () => {
setEditingProject(null);
setEditingName('');
};
const saveProjectName = async (projectName) => {
try {
const response = await fetch(`/api/projects/${projectName}/rename`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ displayName: editingName }),
});
if (response.ok) {
// Refresh projects to get updated data
if (window.refreshProjects) {
window.refreshProjects();
} else {
window.location.reload();
}
} else {
console.error('Failed to rename project');
}
} catch (error) {
console.error('Error renaming project:', error);
}
setEditingProject(null);
setEditingName('');
};
const deleteSession = async (projectName, sessionId) => {
if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
method: 'DELETE',
});
if (response.ok) {
// Call parent callback if provided
if (onSessionDelete) {
onSessionDelete(sessionId);
}
} else {
console.error('Failed to delete session');
alert('Failed to delete session. Please try again.');
}
} catch (error) {
console.error('Error deleting session:', error);
alert('Error deleting session. Please try again.');
}
};
const deleteProject = async (projectName) => {
if (!confirm('Are you sure you want to delete this empty project? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/api/projects/${projectName}`, {
method: 'DELETE',
});
if (response.ok) {
// Call parent callback if provided
if (onProjectDelete) {
onProjectDelete(projectName);
}
} else {
const error = await response.json();
console.error('Failed to delete project');
alert(error.error || 'Failed to delete project. Please try again.');
}
} catch (error) {
console.error('Error deleting project:', error);
alert('Error deleting project. Please try again.');
}
};
const createNewProject = async () => {
if (!newProjectPath.trim()) {
alert('Please enter a project path');
return;
}
setCreatingProject(true);
try {
const response = await fetch('/api/projects/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: newProjectPath.trim()
}),
});
if (response.ok) {
const result = await response.json();
setShowNewProject(false);
setNewProjectPath('');
// Refresh projects to show the new one
if (window.refreshProjects) {
window.refreshProjects();
} else {
window.location.reload();
}
} else {
const error = await response.json();
alert(error.error || 'Failed to create project. Please try again.');
}
} catch (error) {
console.error('Error creating project:', error);
alert('Error creating project. Please try again.');
} finally {
setCreatingProject(false);
}
};
const cancelNewProject = () => {
setShowNewProject(false);
setNewProjectPath('');
};
const loadMoreSessions = async (project) => {
// Check if we can load more sessions
const canLoadMore = project.sessionMeta?.hasMore !== false;
if (!canLoadMore || loadingSessions[project.name]) {
return;
}
setLoadingSessions(prev => ({ ...prev, [project.name]: true }));
try {
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
const response = await fetch(
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
);
if (response.ok) {
const result = await response.json();
// Store additional sessions locally
setAdditionalSessions(prev => ({
...prev,
[project.name]: [
...(prev[project.name] || []),
...result.sessions
]
}));
// Update project metadata if needed
if (result.hasMore === false) {
// Mark that there are no more sessions to load
project.sessionMeta = { ...project.sessionMeta, hasMore: false };
}
}
} catch (error) {
console.error('Error loading more sessions:', error);
} finally {
setLoadingSessions(prev => ({ ...prev, [project.name]: false }));
}
};
// Helper function to get all sessions for a project (initial + additional)
const getAllSessions = (project) => {
const initialSessions = project.sessions || [];
const additional = additionalSessions[project.name] || [];
return [...initialSessions, ...additional];
};
return (
<div className="h-full flex flex-col bg-card md:select-none">
{/* Header */}
<div className="md:p-4 md:border-b md:border-border">
{/* Desktop Header */}
<div className="hidden md:flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
<Button
variant="default"
size="sm"
className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Mobile Header */}
<div className="md:hidden p-3 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">Projects</p>
</div>
</div>
<div className="flex gap-2">
<button
className="w-8 h-8 rounded-md bg-background border border-border flex items-center justify-center active:scale-95 transition-all duration-150"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 text-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
className="w-8 h-8 rounded-md bg-primary text-primary-foreground flex items-center justify-center active:scale-95 transition-all duration-150"
onClick={() => setShowNewProject(true)}
>
<FolderPlus className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
{/* New Project Form */}
{showNewProject && (
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
{/* Desktop Form */}
<div className="hidden md:block space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<FolderPlus className="w-4 h-4" />
Create New Project
</div>
<Input
value={newProjectPath}
onChange={(e) => setNewProjectPath(e.target.value)}
placeholder="/path/to/project or relative/path"
className="text-sm focus:ring-2 focus:ring-primary/20"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') createNewProject();
if (e.key === 'Escape') cancelNewProject();
}}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
>
{creatingProject ? 'Creating...' : 'Create Project'}
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelNewProject}
disabled={creatingProject}
className="h-8 text-xs hover:bg-accent transition-colors"
>
Cancel
</Button>
</div>
</div>
{/* Mobile Form - Simple Overlay */}
<div className="md:hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm">
<div className="absolute bottom-0 left-0 right-0 bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
<FolderPlus className="w-3 h-3 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">New Project</h2>
</div>
</div>
<button
onClick={cancelNewProject}
disabled={creatingProject}
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="space-y-3">
<Input
value={newProjectPath}
onChange={(e) => setNewProjectPath(e.target.value)}
placeholder="/path/to/project or relative/path"
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') createNewProject();
if (e.key === 'Escape') cancelNewProject();
}}
/>
<div className="flex gap-2">
<Button
onClick={cancelNewProject}
disabled={creatingProject}
variant="outline"
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
>
Cancel
</Button>
<Button
onClick={createNewProject}
disabled={!newProjectPath.trim() || creatingProject}
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
>
{creatingProject ? 'Creating...' : 'Create'}
</Button>
</div>
</div>
{/* Safe area for mobile */}
<div className="h-4" />
</div>
</div>
</div>
)}
{/* Projects List */}
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
<div className="md:space-y-1 pb-safe-area-inset-bottom">
{isLoading ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
<p className="text-sm text-muted-foreground">
Fetching your Claude projects and sessions
</p>
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No projects found</h3>
<p className="text-sm text-muted-foreground">
Run Claude CLI in a project directory to get started
</p>
</div>
) : (
projects.map((project) => {
const isExpanded = expandedProjects.has(project.name);
const isSelected = selectedProject?.name === project.name;
return (
<div key={project.name} className="md:space-y-1">
{/* Project Header */}
<div className="group md:group">
{/* Mobile Project Item */}
<div className="md:hidden">
<div
className={cn(
"p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150",
isSelected && "bg-primary/5 border-primary/20"
)}
onClick={() => {
// On mobile, just toggle the folder - don't select the project
toggleProject(project.name);
}}
onTouchEnd={handleTouchClick(() => toggleProject(project.name))}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center transition-colors",
isExpanded ? "bg-primary/10" : "bg-muted"
)}>
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary" />
) : (
<Folder className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
{editingProject === project.name ? (
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="w-full px-3 py-2 text-sm border-2 border-primary/40 focus:border-primary rounded-lg bg-background text-foreground shadow-sm focus:shadow-md transition-all duration-200 focus:outline-none"
placeholder="Project name"
autoFocus
autoComplete="off"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') saveProjectName(project.name);
if (e.key === 'Escape') cancelEditing();
}}
style={{
fontSize: '16px', // Prevents zoom on iOS
WebkitAppearance: 'none',
borderRadius: '8px'
}}
/>
) : (
<>
<h3 className="text-sm font-medium text-foreground truncate">
{project.displayName}
</h3>
<p className="text-xs text-muted-foreground">
{(() => {
const sessionCount = getAllSessions(project).length;
const hasMore = project.sessionMeta?.hasMore !== false;
const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
return `${count} session${count === 1 ? '' : 's'}`;
})()}
</p>
</>
)}
</div>
</div>
<div className="flex items-center gap-1">
{editingProject === project.name ? (
<>
<button
className="w-8 h-8 rounded-lg bg-green-500 dark:bg-green-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
onClick={(e) => {
e.stopPropagation();
saveProjectName(project.name);
}}
>
<Check className="w-4 h-4 text-white" />
</button>
<button
className="w-8 h-8 rounded-lg bg-gray-500 dark:bg-gray-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
onClick={(e) => {
e.stopPropagation();
cancelEditing();
}}
>
<X className="w-4 h-4 text-white" />
</button>
</>
) : (
<>
{getAllSessions(project).length === 0 && (
<button
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 transition-all duration-150 border border-red-200 dark:border-red-800"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
}}
onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
)}
<button
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 transition-all duration-150 border border-primary/20 dark:border-primary/30"
onClick={(e) => {
e.stopPropagation();
startEditing(project);
}}
onTouchEnd={handleTouchClick(() => startEditing(project))}
>
<Edit3 className="w-4 h-4 text-primary" />
</button>
<div className="w-6 h-6 rounded-md bg-muted/30 flex items-center justify-center">
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</div>
</>
)}
</div>
</div>
</div>
</div>
{/* Desktop Project Item */}
<Button
variant="ghost"
className={cn(
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50 transition-colors duration-200",
isSelected && "bg-accent text-accent-foreground"
)}
onClick={() => {
// Desktop behavior: select project and toggle
if (selectedProject?.name !== project.name) {
onProjectSelect(project);
}
toggleProject(project.name);
}}
onTouchEnd={handleTouchClick(() => {
if (selectedProject?.name !== project.name) {
onProjectSelect(project);
}
toggleProject(project.name);
})}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<div className="min-w-0 flex-1 text-left">
{editingProject === project.name ? (
<div className="space-y-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
placeholder="Project name"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') saveProjectName(project.name);
if (e.key === 'Escape') cancelEditing();
}}
/>
<div className="text-xs text-muted-foreground truncate" title={project.fullPath}>
{project.fullPath}
</div>
</div>
) : (
<div>
<div className="text-sm font-semibold truncate text-foreground" title={project.displayName}>
{project.displayName}
</div>
<div className="text-xs text-muted-foreground">
{(() => {
const sessionCount = getAllSessions(project).length;
const hasMore = project.sessionMeta?.hasMore !== false;
return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
})()}
{project.fullPath !== project.displayName && (
<span className="ml-1 opacity-60" title={project.fullPath}>
{project.fullPath.length > 25 ? '...' + project.fullPath.slice(-22) : project.fullPath}
</span>
)}
</div>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{editingProject === project.name ? (
<>
<div
className="w-6 h-6 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 flex items-center justify-center rounded cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
saveProjectName(project.name);
}}
>
<Check className="w-3 h-3" />
</div>
<div
className="w-6 h-6 text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 flex items-center justify-center rounded cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
cancelEditing();
}}
>
<X className="w-3 h-3" />
</div>
</>
) : (
<>
<div
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-accent flex items-center justify-center rounded cursor-pointer touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
startEditing(project);
}}
title="Rename project (F2)"
>
<Edit3 className="w-3 h-3" />
</div>
{getAllSessions(project).length === 0 && (
<div
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
}}
title="Delete empty project (Delete)"
>
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</div>
)}
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</>
)}
</div>
</Button>
</div>
{/* Sessions List */}
{isExpanded && (
<div className="ml-3 space-y-1 border-l border-border pl-3">
{!initialSessionsLoaded.has(project.name) ? (
// Loading skeleton for sessions
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-2 rounded-md">
<div className="flex items-start gap-2">
<div className="w-3 h-3 bg-muted rounded-full animate-pulse mt-0.5" />
<div className="flex-1 space-y-1">
<div className="h-3 bg-muted rounded animate-pulse" style={{ width: `${60 + i * 15}%` }} />
<div className="h-2 bg-muted rounded animate-pulse w-1/2" />
</div>
</div>
</div>
))
) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (
<div className="py-2 px-3 text-left">
<p className="text-xs text-muted-foreground">No sessions yet</p>
</div>
) : (
getAllSessions(project).map((session) => (
<div key={session.id} className="group relative">
{/* Mobile Session Item */}
<div className="md:hidden">
<div
className={cn(
"p-2 mx-3 my-0.5 rounded-md bg-card border border-border/30 active:scale-[0.98] transition-all duration-150 relative",
selectedSession?.id === session.id && "bg-primary/5 border-primary/20"
)}
onClick={() => {
onProjectSelect(project);
onSessionSelect(session);
}}
onTouchEnd={handleTouchClick(() => {
onProjectSelect(project);
onSessionSelect(session);
})}
>
<div className="flex items-center gap-2">
<div className={cn(
"w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
)}>
<MessageSquare className={cn(
"w-3 h-3",
selectedSession?.id === session.id ? "text-primary" : "text-muted-foreground"
)} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
</span>
{session.messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
</Badge>
)}
</div>
</div>
{/* Mobile delete button */}
<button
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
</div>
</div>
</div>
{/* Desktop Session Item */}
<div className="hidden md:block">
<Button
variant="ghost"
className={cn(
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200",
selectedSession?.id === session.id && "bg-accent text-accent-foreground"
)}
onClick={() => onSessionSelect(session)}
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
>
<div className="flex items-start gap-2 min-w-0 w-full">
<MessageSquare className="w-3 h-3 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate text-foreground">
{session.summary || 'New Session'}
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(session.lastActivity, currentTime)}
</span>
{session.messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
{session.messageCount}
</Badge>
)}
</div>
</div>
</div>
</Button>
{/* Desktop delete button */}
<button
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id);
}}
title="Delete session (Delete)"
>
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</button>
</div>
</div>
))
)}
{/* Show More Sessions Button */}
{getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && (
<Button
variant="ghost"
size="sm"
className="w-full justify-center gap-2 mt-2 text-muted-foreground"
onClick={() => loadMoreSessions(project)}
disabled={loadingSessions[project.name]}
>
{loadingSessions[project.name] ? (
<>
<div className="w-3 h-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
Loading...
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
Show more sessions
</>
)}
</Button>
)}
{/* New Session Button */}
<div className="md:hidden px-3 pb-2">
<button
className="w-full h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md flex items-center justify-center gap-2 font-medium text-xs active:scale-[0.98] transition-all duration-150"
onClick={() => {
onProjectSelect(project);
onNewSession(project);
}}
>
<Plus className="w-3 h-3" />
New Session
</button>
</div>
<Button
variant="default"
size="sm"
className="hidden md:flex w-full justify-start gap-2 mt-1 h-8 text-xs font-medium bg-primary hover:bg-primary/90 text-primary-foreground transition-colors"
onClick={() => onNewSession(project)}
>
<Plus className="w-3 h-3" />
New Session
</Button>
</div>
)}
</div>
);
})
)}
</div>
</ScrollArea>
{/* Settings Section */}
<div className="md:p-2 md:border-t md:border-border flex-shrink-0">
{/* Mobile Settings */}
<div className="md:hidden p-4 pb-20 border-t border-border/50">
<button
className="w-full h-14 bg-muted/50 hover:bg-muted/70 rounded-2xl flex items-center justify-start gap-4 px-4 active:scale-[0.98] transition-all duration-150"
onClick={onShowSettings}
>
<div className="w-10 h-10 rounded-2xl bg-background/80 flex items-center justify-center">
<Settings className="w-5 h-5 text-muted-foreground" />
</div>
<span className="text-lg font-medium text-foreground">Settings</span>
</button>
</div>
{/* Desktop Settings */}
<Button
variant="ghost"
className="hidden md:flex w-full justify-start gap-2 p-2 h-auto font-normal text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200"
onClick={onShowSettings}
>
<Settings className="w-3 h-3" />
<span className="text-xs">Tools Settings</span>
</Button>
</div>
</div>
);
}
export default Sidebar;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Badge } from './ui/badge';
import { CheckCircle2, Clock, Circle } from 'lucide-react';
const TodoList = ({ todos, isResult = false }) => {
if (!todos || !Array.isArray(todos)) {
return null;
}
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'in_progress':
return <Clock className="w-4 h-4 text-blue-500" />;
case 'pending':
default:
return <Circle className="w-4 h-4 text-gray-400" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800 border-green-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'pending':
default:
return 'bg-gray-100 text-gray-600 border-gray-200';
}
};
const getPriorityColor = (priority) => {
switch (priority) {
case 'high':
return 'bg-red-100 text-red-700 border-red-200';
case 'medium':
return 'bg-yellow-100 text-yellow-700 border-yellow-200';
case 'low':
default:
return 'bg-gray-100 text-gray-600 border-gray-200';
}
};
return (
<div className="space-y-3">
{isResult && (
<div className="text-sm font-medium text-gray-700 mb-3">
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
</div>
)}
{todos.map((todo) => (
<div
key={todo.id}
className="flex items-start gap-3 p-3 bg-white border rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<p className={`text-sm font-medium ${todo.status === 'completed' ? 'line-through text-gray-500' : 'text-gray-900'}`}>
{todo.content}
</p>
<div className="flex gap-1 flex-shrink-0">
<Badge
variant="outline"
className={`text-xs px-2 py-0.5 ${getPriorityColor(todo.priority)}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`text-xs px-2 py-0.5 ${getStatusColor(todo.status)}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default TodoList;

View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Badge } from './ui/badge';
import { X, Plus, Settings, Shield, AlertTriangle } from 'lucide-react';
function ToolsSettings({ isOpen, onClose }) {
const [allowedTools, setAllowedTools] = useState([]);
const [disallowedTools, setDisallowedTools] = useState([]);
const [newAllowedTool, setNewAllowedTool] = useState('');
const [newDisallowedTool, setNewDisallowedTool] = useState('');
const [skipPermissions, setSkipPermissions] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
// Common tool patterns
const commonTools = [
'Bash(git log:*)',
'Bash(git diff:*)',
'Bash(git status:*)',
'Write',
'Read',
'Edit',
'Glob',
'Grep',
'MultiEdit',
'Task',
'TodoWrite',
'TodoRead',
'WebFetch',
'WebSearch'
];
useEffect(() => {
if (isOpen) {
loadSettings();
}
}, [isOpen]);
const loadSettings = () => {
try {
// Load from localStorage
const savedSettings = localStorage.getItem('claude-tools-settings');
if (savedSettings) {
const settings = JSON.parse(savedSettings);
setAllowedTools(settings.allowedTools || []);
setDisallowedTools(settings.disallowedTools || []);
setSkipPermissions(settings.skipPermissions || false);
} else {
// Set defaults
setAllowedTools([]);
setDisallowedTools([]);
setSkipPermissions(false);
}
} catch (error) {
console.error('Error loading tool settings:', error);
// Set defaults on error
setAllowedTools([]);
setDisallowedTools([]);
setSkipPermissions(false);
}
};
const saveSettings = () => {
setIsSaving(true);
setSaveStatus(null);
try {
const settings = {
allowedTools,
disallowedTools,
skipPermissions,
lastUpdated: new Date().toISOString()
};
// Save to localStorage
localStorage.setItem('claude-tools-settings', JSON.stringify(settings));
setSaveStatus('success');
setTimeout(() => {
onClose();
}, 1000);
} catch (error) {
console.error('Error saving tool settings:', error);
setSaveStatus('error');
} finally {
setIsSaving(false);
}
};
const addAllowedTool = (tool) => {
if (tool && !allowedTools.includes(tool)) {
setAllowedTools([...allowedTools, tool]);
setNewAllowedTool('');
}
};
const removeAllowedTool = (tool) => {
setAllowedTools(allowedTools.filter(t => t !== tool));
};
const addDisallowedTool = (tool) => {
if (tool && !disallowedTools.includes(tool)) {
setDisallowedTools([...disallowedTools, tool]);
setNewDisallowedTool('');
}
};
const removeDisallowedTool = (tool) => {
setDisallowedTools(disallowedTools.filter(t => t !== tool));
};
if (!isOpen) return null;
return (
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-background/95">
<div className="bg-background border border-border md:rounded-lg shadow-xl w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
<h2 className="text-lg md:text-xl font-semibold text-foreground">
Tools Settings
</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-muted-foreground hover:text-foreground touch-manipulation"
>
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-4 md:p-6 space-y-6 md:space-y-8 pb-safe-area-inset-bottom">
{/* Skip Permissions */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-medium text-foreground">
Permission Settings
</h3>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={skipPermissions}
onChange={(e) => setSkipPermissions(e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
Skip permission prompts (use with caution)
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
Equivalent to --dangerously-skip-permissions flag
</div>
</div>
</label>
</div>
</div>
{/* Allowed Tools */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">
Allowed Tools
</h3>
</div>
<p className="text-sm text-muted-foreground">
Tools that are automatically allowed without prompting for permission
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newAllowedTool}
onChange={(e) => setNewAllowedTool(e.target.value)}
placeholder='e.g., "Bash(git log:*)" or "Write"'
onKeyPress={(e) => {
if (e.key === 'Enter') {
addAllowedTool(newAllowedTool);
}
}}
className="flex-1 h-10 touch-manipulation"
style={{ fontSize: '16px' }}
/>
<Button
onClick={() => addAllowedTool(newAllowedTool)}
disabled={!newAllowedTool}
size="sm"
className="h-10 px-4 touch-manipulation"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add Tool</span>
</Button>
</div>
{/* Common tools quick add */}
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Quick add common tools:
</p>
<div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2">
{commonTools.map(tool => (
<Button
key={tool}
variant="outline"
size="sm"
onClick={() => addAllowedTool(tool)}
disabled={allowedTools.includes(tool)}
className="text-xs h-8 touch-manipulation truncate"
>
{tool}
</Button>
))}
</div>
</div>
<div className="space-y-2">
{allowedTools.map(tool => (
<div key={tool} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<span className="font-mono text-sm text-green-800 dark:text-green-200">
{tool}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeAllowedTool(tool)}
className="text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{allowedTools.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No allowed tools configured
</div>
)}
</div>
</div>
{/* Disallowed Tools */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-medium text-foreground">
Disallowed Tools
</h3>
</div>
<p className="text-sm text-muted-foreground">
Tools that are automatically blocked without prompting for permission
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newDisallowedTool}
onChange={(e) => setNewDisallowedTool(e.target.value)}
placeholder='e.g., "Bash(rm:*)" or "Write"'
onKeyPress={(e) => {
if (e.key === 'Enter') {
addDisallowedTool(newDisallowedTool);
}
}}
className="flex-1 h-10 touch-manipulation"
style={{ fontSize: '16px' }}
/>
<Button
onClick={() => addDisallowedTool(newDisallowedTool)}
disabled={!newDisallowedTool}
size="sm"
className="h-10 px-4 touch-manipulation"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add Tool</span>
</Button>
</div>
<div className="space-y-2">
{disallowedTools.map(tool => (
<div key={tool} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<span className="font-mono text-sm text-red-800 dark:text-red-200">
{tool}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeDisallowedTool(tool)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{disallowedTools.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No disallowed tools configured
</div>
)}
</div>
</div>
{/* Help Section */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
Tool Pattern Examples:
</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git log:*)"</code> - Allow all git log commands</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> - Allow all git diff commands</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> - Allow all Write tool usage</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Read"</code> - Allow all Read tool usage</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
</ul>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-4 md:p-6 border-t border-border flex-shrink-0 gap-3 pb-safe-area-inset-bottom">
<div className="flex items-center justify-center sm:justify-start gap-2 order-2 sm:order-1">
{saveStatus === 'success' && (
<div className="text-green-600 dark:text-green-400 text-sm flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
Settings saved successfully!
</div>
)}
{saveStatus === 'error' && (
<div className="text-red-600 dark:text-red-400 text-sm flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Failed to save settings
</div>
)}
</div>
<div className="flex items-center gap-3 order-1 sm:order-2">
<Button
variant="outline"
onClick={onClose}
disabled={isSaving}
className="flex-1 sm:flex-none h-10 touch-manipulation"
>
Cancel
</Button>
<Button
onClick={saveSettings}
disabled={isSaving}
className="flex-1 sm:flex-none h-10 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 touch-manipulation"
>
{isSaving ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Saving...
</div>
) : (
'Save Settings'
)}
</Button>
</div>
</div>
</div>
</div>
);
}
export default ToolsSettings;

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "../../lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<div className="h-full w-full rounded-[inherit] overflow-auto">
{children}
</div>
</div>
))
ScrollArea.displayName = "ScrollArea"
export { ScrollArea }

254
src/index.css Normal file
View File

@@ -0,0 +1,254 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
box-sizing: border-box;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground));
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.8);
}
}
/* Mobile optimizations and components */
@layer components {
/* Mobile touch optimization and safe areas */
@media (max-width: 768px) {
* {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
button,
[role="button"],
.clickable,
a,
.cursor-pointer {
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
/* Better mobile touch targets */
.mobile-touch-target {
@apply min-h-[44px] min-w-[44px];
}
/* Chat message improvements */
.chat-message.user {
@apply justify-end;
}
.chat-message.user > div {
@apply max-w-[85%];
}
.chat-message.assistant > div,
.chat-message.error > div {
@apply w-full sm:max-w-[95%];
}
/* Enable text selection on mobile for terminal */
.xterm,
.xterm .xterm-viewport {
-webkit-user-select: text !important;
user-select: text !important;
-webkit-touch-callout: default !important;
}
/* Fix mobile scrolling */
.overflow-y-auto {
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.chat-message {
touch-action: pan-y;
}
/* Fix hover states on mobile */
.group:active .group-hover\:opacity-100,
.group .group-hover\:opacity-100 {
opacity: 1 !important;
}
@media (hover: none) and (pointer: coarse) {
.group-hover\:opacity-100 {
opacity: 1 !important;
}
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:bg-red-200:hover,
.dark\:hover\:bg-gray-700:hover,
.dark\:hover\:bg-red-900\/50:hover {
background-color: inherit;
}
}
}
/* Touch device optimizations for all screen sizes */
@media (hover: none) and (pointer: coarse) {
.touch\:opacity-100 {
opacity: 1 !important;
}
/* Completely disable hover states on touch devices */
* {
-webkit-tap-highlight-color: transparent !important;
}
/* Only disable hover states for interactive elements, not containers */
button:hover,
[role="button"]:hover,
.cursor-pointer:hover,
a:hover,
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:text-gray-900:hover,
.hover\:opacity-100:hover {
background-color: inherit !important;
color: inherit !important;
opacity: inherit !important;
transform: inherit !important;
}
/* Preserve backgrounds for containers and modals */
.fixed:hover,
.modal:hover,
.bg-white:hover,
.bg-gray-800:hover,
.bg-gray-900:hover,
[class*="bg-"]:hover {
background-color: revert !important;
}
/* Force buttons to be immediately clickable */
button, [role="button"], .cursor-pointer {
cursor: pointer !important;
pointer-events: auto !important;
}
/* Keep active states for immediate feedback */
.active\:scale-\[0\.98\]:active,
.active\:scale-95:active {
transform: scale(0.98) !important;
}
}
/* Safe area support for iOS devices */
.ios-bottom-safe {
padding-bottom: max(env(safe-area-inset-bottom), 12px);
}
@media screen and (max-width: 768px) {
.chat-input-mobile {
padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
}
}
/* Text wrapping improvements */
.chat-message {
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Force wrap long URLs and code */
.chat-message pre,
.chat-message code {
white-space: pre-wrap !important;
word-break: break-all;
overflow-wrap: break-word;
}
/* Prevent horizontal scroll in chat area */
.chat-message * {
max-width: 100%;
box-sizing: border-box;
}
}

6
src/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

97
src/utils/websocket.js Normal file
View File

@@ -0,0 +1,97 @@
import { useState, useEffect, useRef } from 'react';
export function useWebSocket() {
const [ws, setWs] = useState(null);
const [messages, setMessages] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef(null);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (ws) {
ws.close();
}
};
}, []);
const connect = async () => {
try {
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
// If the config returns localhost but we're not on localhost, use current host but with API server port
if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) {
console.warn('Config returned localhost, using current host with API server port instead');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
} catch (error) {
console.warn('Could not fetch server config, falling back to current host with API server port');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// For development, API server is typically on port 3002 when Vite is on 3001
const apiPort = window.location.port === '3001' ? '3002' : window.location.port;
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/ws`;
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
setIsConnected(true);
setWs(websocket);
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setMessages(prev => [...prev, data]);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
websocket.onclose = () => {
setIsConnected(false);
setWs(null);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
}
};
const sendMessage = (message) => {
if (ws && isConnected) {
ws.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected');
}
};
return {
ws,
sendMessage,
messages,
isConnected
};
}

63
tailwind.config.js Normal file
View File

@@ -0,0 +1,63 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
spacing: {
'safe-area-inset-bottom': 'env(safe-area-inset-bottom)',
},
},
},
plugins: [require('@tailwindcss/typography')],
}

19
vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: process.env.VITE_PORT || 3001,
proxy: {
'/api': `http://localhost:${process.env.PORT || 3002}`,
'/ws': {
target: `ws://localhost:${process.env.PORT || 3002}`,
ws: true
}
}
},
build: {
outDir: 'dist'
}
})